├── deploy ├── 01-issuer.yaml ├── 00-secret.yaml ├── .env.example ├── 02-certificate.yaml ├── 03-deployment.yaml └── create-talos-machine.sh ├── .ko.yaml ├── .golangci.yaml ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── latest.yml │ └── ci.yaml ├── pkg ├── proto │ ├── security.proto │ ├── security_grpc.pb.go │ └── security.pb.go ├── errors │ └── errors.go └── server │ └── server.go ├── go.mod ├── go.sum ├── Makefile ├── main.go ├── LICENSE ├── docs ├── standalone-deployment.md └── sidecar-deployment.md └── README.md /deploy/01-issuer.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: cert-manager.io/v1 2 | kind: Issuer 3 | metadata: 4 | name: ${CLUSTER_NAME}-talos-ca 5 | namespace: $NAMESPACE 6 | spec: 7 | ca: 8 | secretName: ${CLUSTER_NAME}-talos-ca 9 | -------------------------------------------------------------------------------- /.ko.yaml: -------------------------------------------------------------------------------- 1 | defaultPlatforms: 2 | - linux/arm64 3 | - linux/amd64 4 | builds: 5 | - id: kamaji-addon-talos 6 | main: . 7 | ldflags: 8 | - '{{ if index .Env "LD_FLAGS" }}{{ .Env.LD_FLAGS }}{{ end }}' 9 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: all 4 | disable: 5 | - cyclop 6 | - depguard 7 | - exhaustruct 8 | - funlen 9 | - lll 10 | - mnd 11 | - varnamelen 12 | settings: 13 | goheader: 14 | template: |- 15 | Copyright 2025 Clastix Labs 16 | SPDX-License-Identifier: Apache-2.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries 2 | bin/ 3 | *.exe 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Go 9 | *.test 10 | *.out 11 | go.work 12 | 13 | # Generated protobuf code 14 | proto/*.pb.go 15 | 16 | # IDE 17 | .idea/ 18 | .vscode/ 19 | *.swp 20 | *.swo 21 | *~ 22 | 23 | # OS 24 | .DS_Store 25 | Thumbs.db 26 | 27 | # Documentation 28 | .CLAUDE.md 29 | 30 | # Secrets (local testing) 31 | *.key 32 | *.crt 33 | *.pem 34 | secrets/ 35 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: daily 7 | rebase-strategy: disabled 8 | commit-message: 9 | prefix: "feat(deps)" 10 | - package-ecosystem: github-actions 11 | directory: / 12 | schedule: 13 | interval: daily 14 | rebase-strategy: disabled 15 | commit-message: 16 | prefix: "chore(ci)" 17 | -------------------------------------------------------------------------------- /deploy/00-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: ${CLUSTER_NAME}-talos-ca 5 | namespace: $NAMESPACE 6 | type: Opaque 7 | data: 8 | # Talos Machine CA Certificate (base64 from talosctl gen config) 9 | # Copy from controlplane.yaml -> machine.ca.crt 10 | tls.crt: "" 11 | # Talos Machine CA Private Key (base64 from talosctl gen config) 12 | # Copy from controlplane.yaml -> machine.ca.key 13 | tls.key: "" 14 | # Talos bootstrap token (from worker config -> cluster.token) 15 | # Example: u1auey.78f6fcj1pzoaur2i 16 | token: "" -------------------------------------------------------------------------------- /deploy/.env.example: -------------------------------------------------------------------------------- 1 | # Talos + Kamaji Configuration 2 | # Copy this file to .env and update with your values 3 | # Usage: source .env 4 | 5 | # Cluster configuration 6 | export CLUSTER_NAME="sample" 7 | export NAMESPACE="default" 8 | export CONTROL_PLANE_IP="10.10.10.100" 9 | export KUBERNETES_VERSION="v1.33.2" 10 | export TALOS_VERSION="v1.8.3" 11 | 12 | # Worker node IP addresses (space-separated) 13 | # Add or remove IPs as needed 14 | export WORKER_IPS="192.168.11.101 192.168.11.102 192.168.11.103" 15 | 16 | # Optional: Registry for CSR Signer image 17 | export CSR_SIGNER_IMAGE="ghcr.io/clastix/talos-csr-signer" 18 | export CSR_SIGNER_IMAGE_TAG="latest" -------------------------------------------------------------------------------- /deploy/02-certificate.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: cert-manager.io/v1 2 | kind: Certificate 3 | metadata: 4 | name: ${CLUSTER_NAME}-talos-tls-cert 5 | namespace: $NAMESPACE 6 | spec: 7 | secretName: ${CLUSTER_NAME}-talos-tls-cert 8 | duration: 8760h # 1 year 9 | renewBefore: 720h # 30 days before 10 | isCA: false 11 | privateKey: 12 | algorithm: Ed25519 13 | size: 256 14 | usages: 15 | - digital signature 16 | - key encipherment 17 | - server auth 18 | ipAddresses: 19 | - 127.0.0.1 20 | # Add your custom IPs here 21 | issuerRef: 22 | name: ${CLUSTER_NAME}-talos-ca # 01-issuer.yaml 23 | kind: Issuer 24 | group: cert-manager.io 25 | -------------------------------------------------------------------------------- /.github/workflows/latest.yml: -------------------------------------------------------------------------------- 1 | name: Container image build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | ko: 10 | permissions: 11 | contents: read 12 | packages: write 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - uses: actions/checkout@v5 16 | with: 17 | fetch-depth: 0 18 | - uses: actions/setup-go@v6 19 | with: 20 | go-version-file: go.mod 21 | - name: Login to GitHub Container Registry 22 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin 23 | - name: "ko: install" 24 | run: make ko 25 | - name: "ko: build and push latest tag" 26 | run: make VERSION=latest KO_LOCAL=false KO_PUSH=true oci-build 27 | -------------------------------------------------------------------------------- /pkg/proto/security.proto: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | // 5 | // This file is derived from: 6 | // https://github.com/siderolabs/talos/blob/main/api/security/security.proto 7 | // Copyright (c) 2024 Sidero Labs, Inc. 8 | 9 | syntax = "proto3"; 10 | 11 | package securityapi; 12 | 13 | option go_package = "github.com/clastix/talos-csr-signer/proto;securityapi"; 14 | 15 | // SecurityService provides certificate signing for Talos workers 16 | service SecurityService { 17 | rpc Certificate(CertificateRequest) returns (CertificateResponse); 18 | } 19 | 20 | // CertificateRequest contains a PEM-encoded CSR 21 | message CertificateRequest { 22 | bytes csr = 1; 23 | } 24 | 25 | // CertificateResponse contains the CA cert and signed certificate 26 | message CertificateResponse { 27 | bytes ca = 1; // CA certificate in PEM format 28 | bytes crt = 2; // Signed certificate in PEM format 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "*" ] 6 | pull_request: 7 | branches: [ "*" ] 8 | 9 | jobs: 10 | diff: 11 | name: diff 12 | runs-on: ubuntu-22.04 13 | steps: 14 | - uses: actions/checkout@v5 15 | with: 16 | fetch-depth: 0 17 | - uses: actions/setup-go@v6 18 | with: 19 | go-version-file: go.mod 20 | - run: make proto 21 | - name: Checking if generated protobuffer files are not aligned 22 | run: if [[ $(git diff | wc -l) -gt 0 ]]; then echo ">>> Untracked generated files have not been committed" && git --no-pager diff && exit 1; fi 23 | - name: Checking if missing untracked files for generated protobuf 24 | run: test -z "$(git ls-files --others --exclude-standard 2> /dev/null)" 25 | golangci: 26 | name: lint 27 | runs-on: ubuntu-22.04 28 | steps: 29 | - uses: actions/checkout@v5 30 | - uses: actions/setup-go@v6 31 | with: 32 | go-version-file: go.mod 33 | - name: Run golangci-lint 34 | run: make lint 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/clastix/talos-csr-signer 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/pkg/errors v0.9.1 7 | google.golang.org/grpc v1.68.1 8 | google.golang.org/protobuf v1.35.2 9 | ) 10 | 11 | require ( 12 | github.com/fsnotify/fsnotify v1.9.0 // indirect 13 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 14 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 15 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 16 | github.com/sagikazarmark/locafero v0.11.0 // indirect 17 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 18 | github.com/spf13/afero v1.15.0 // indirect 19 | github.com/spf13/cast v1.10.0 // indirect 20 | github.com/spf13/cobra v1.10.1 // indirect 21 | github.com/spf13/pflag v1.0.10 // indirect 22 | github.com/spf13/viper v1.21.0 // indirect 23 | github.com/subosito/gotenv v1.6.0 // indirect 24 | go.yaml.in/yaml/v3 v3.0.4 // indirect 25 | golang.org/x/net v0.46.0 // indirect 26 | golang.org/x/sys v0.37.0 // indirect 27 | golang.org/x/text v0.30.0 // indirect 28 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Clastix Labs 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // Package errors contains the errors returned by the Kamaji Talos addon. 5 | package errors 6 | 7 | import ( 8 | "errors" 9 | ) 10 | 11 | var ( 12 | // ErrDecodedCACertificate is the error when Certificate Authority decoding has failed. 13 | ErrDecodedCACertificate = errors.New("failed to decode CA certificate") 14 | // ErrMissingPort is the error when a zero value for port is defined. 15 | ErrMissingPort = errors.New("missing gRPC server port") 16 | // ErrPortOutOfRange is the error when a port is out of range. 17 | ErrPortOutOfRange = errors.New("gRPC server port is out of range") 18 | // ErrMissingToken is the error when a Talos token is not specified, required for the CSR process. 19 | ErrMissingToken = errors.New("missing Talos token") 20 | // ErrMissingPath is the error when a certificate component path is not declared. 21 | ErrMissingPath = errors.New("path is required") 22 | // ErrReadFile is the error when reading the certificate components from a path. 23 | ErrReadFile = errors.New("failed to read file") 24 | // ErrPemDecoding is the error when decoding the certificate PEM. 25 | ErrPemDecoding = errors.New("failed to decode PEM") 26 | // ErrParseCertificate is the error when parsing the certificate private key. 27 | ErrParseCertificate = errors.New("failed to parse private key") 28 | // ErrUnsupportedBlockType is the error when trying to parse a certificate with an unhandled block. 29 | ErrUnsupportedBlockType = errors.New("unsupported block type") 30 | // ErrLoadingCertificate is the error when loading the certificate from certificate and key from the FS. 31 | ErrLoadingCertificate = errors.New("failed to load certificate") 32 | // ErrServerListen is the error when the server can't start listening on the given port. 33 | ErrServerListen = errors.New("failed to listen on given port") 34 | // ErrGRPCServerServe is the error when the gRPC server is not hable to serve requests. 35 | ErrGRPCServerServe = errors.New("failed to serve gRPC") 36 | ) 37 | -------------------------------------------------------------------------------- /deploy/03-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: talos-csr-signer 5 | namespace: default 6 | labels: 7 | app: talos-csr-signer 8 | spec: 9 | replicas: 1 # 2 for high availability 10 | selector: 11 | matchLabels: 12 | app: talos-csr-signer 13 | template: 14 | metadata: 15 | labels: 16 | app: talos-csr-signer 17 | spec: 18 | containers: 19 | - name: talos-csr-signer 20 | image: ghcr.io/clastix/talos-csr-signer:latest 21 | imagePullPolicy: Always 22 | ports: 23 | - name: grpc 24 | containerPort: 50001 25 | protocol: TCP 26 | env: 27 | - name: TALOS_TOKEN 28 | valueFrom: 29 | secretKeyRef: 30 | name: talos-machine-ca 31 | key: token 32 | volumeMounts: 33 | - name: ca-certs 34 | mountPath: /etc/talos-ca 35 | readOnly: true 36 | - name: tls-cert 37 | mountPath: /etc/talos-server-crt 38 | readOnly: true 39 | resources: 40 | requests: 41 | cpu: "100m" 42 | memory: "64Mi" 43 | limits: 44 | cpu: "200m" 45 | memory: "128Mi" 46 | livenessProbe: 47 | tcpSocket: 48 | port: 50001 49 | initialDelaySeconds: 10 50 | periodSeconds: 10 51 | readinessProbe: 52 | tcpSocket: 53 | port: 50001 54 | initialDelaySeconds: 5 55 | periodSeconds: 5 56 | securityContext: 57 | allowPrivilegeEscalation: false 58 | runAsNonRoot: true 59 | runAsUser: 65532 60 | capabilities: 61 | drop: 62 | - ALL 63 | readOnlyRootFilesystem: true 64 | volumes: 65 | - name: ca-certs 66 | secret: 67 | secretName: talos-machine-ca # 00-secret.yaml 68 | items: 69 | - key: ca.crt 70 | path: ca.crt 71 | - key: ca.key 72 | path: ca.key 73 | defaultMode: 0400 74 | - name: tls-cert 75 | secret: 76 | secretName: talos-tls-cert # 01-certificate.yaml 77 | items: 78 | - key: tls.crt 79 | path: tls.crt 80 | - key: tls.key 81 | path: tls.key 82 | defaultMode: 0400 83 | securityContext: 84 | fsGroup: 65532 85 | runAsNonRoot: true 86 | runAsUser: 65532 87 | -------------------------------------------------------------------------------- /deploy/create-talos-machine.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Talos Worker VM Creation Script 5 | # This script creates a Talos worker VM on Proxmox 6 | 7 | # Configuration 8 | VMID=121 9 | VM_NAME="talos-worker-01" 10 | STORAGE="local" 11 | BRIDGE="vnet1" 12 | IMAGE="/var/lib/vz/template/iso/talos-v1.11.3-nocloud-amd64.raw" 13 | MEMORY=4096 14 | CORES=4 15 | DISK_SIZE="+16G" 16 | 17 | echo "=========================================" 18 | echo "Talos Worker VM Creation Script" 19 | echo "=========================================" 20 | echo "VMID: $VMID" 21 | echo "Name: $VM_NAME" 22 | echo "Storage: $STORAGE" 23 | echo "Bridge: $BRIDGE" 24 | echo "Memory: ${MEMORY}MB" 25 | echo "Cores: $CORES" 26 | echo "=========================================" 27 | 28 | # Check if VM exists and stop/destroy it 29 | if qm status $VMID &>/dev/null; then 30 | echo "VM $VMID exists. Stopping and destroying..." 31 | qm stop $VMID 2>/dev/null || true 32 | sleep 2 33 | qm destroy $VMID 34 | echo "VM $VMID destroyed." 35 | else 36 | echo "VM $VMID does not exist. Proceeding with creation..." 37 | fi 38 | 39 | echo "" 40 | echo "Creating VM $VMID..." 41 | qm create $VMID \ 42 | --name "$VM_NAME" \ 43 | --memory $MEMORY \ 44 | --cores $CORES \ 45 | --cpu host \ 46 | --net0 virtio,bridge=$BRIDGE \ 47 | --scsihw virtio-scsi-pci \ 48 | --pool talos 49 | 50 | echo "Importing disk..." 51 | qm importdisk $VMID "$IMAGE" $STORAGE 52 | 53 | echo "Configuring disk and boot..." 54 | qm set $VMID \ 55 | --scsi0 ${STORAGE}:${VMID}/vm-${VMID}-disk-0.raw \ 56 | --boot order=scsi0 \ 57 | --ostype l26 58 | 59 | echo "Configuring EFI disk..." 60 | qm set $VMID --efidisk0 ${STORAGE}:1,efitype=4m,pre-enrolled-keys=0 61 | 62 | echo "Configuring serial console (required for qm terminal)..." 63 | qm set $VMID --serial0 socket --vga std 64 | 65 | echo "Resizing disk..." 66 | qm resize $VMID scsi0 $DISK_SIZE 67 | 68 | echo "" 69 | echo "Starting VM $VMID..." 70 | qm start $VMID 71 | 72 | echo "" 73 | echo "=========================================" 74 | echo "VM Creation Complete!" 75 | echo "=========================================" 76 | echo "" 77 | echo "Next steps:" 78 | echo "1. Wait 30 seconds for VM to boot" 79 | echo "2. On kamaji-cmp host, run:" 80 | echo " cd /home/bsctl/in-progress" 81 | echo " talosctl apply-config --nodes 192.168.11.100 --file talos-worker-milano.yaml --insecure" 82 | echo "" 83 | echo "3. Monitor console with: qm terminal $VMID" 84 | echo " (Press Ctrl+O to exit console)" 85 | echo "" 86 | echo "4. Check CSR signer logs for certificate requests:" 87 | echo " kubectl logs -n default -l app=talos-csr-signer --tail=50" 88 | echo "" 89 | echo "=========================================" 90 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 3 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 4 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 5 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 6 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 7 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 8 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 9 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 10 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 11 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 12 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 13 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 14 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 15 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 16 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 17 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 18 | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 19 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 20 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 21 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 22 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 23 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 24 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 25 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 26 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 27 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 28 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 29 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 30 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 31 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 32 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 33 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 34 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 35 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 36 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 37 | golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 38 | golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 39 | golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 40 | golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 41 | golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= 42 | golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 43 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= 44 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= 45 | google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= 46 | google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= 47 | google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= 48 | google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 51 | -------------------------------------------------------------------------------- /pkg/server/server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Clastix Labs 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // Package server is the gRPC implementation for the Talos SecurityServiceServer interface signature. 5 | package server 6 | 7 | import ( 8 | "context" 9 | "crypto/rand" 10 | "crypto/x509" 11 | "encoding/pem" 12 | "fmt" 13 | "log" 14 | "math/big" 15 | "time" 16 | 17 | "google.golang.org/grpc/codes" 18 | "google.golang.org/grpc/metadata" 19 | "google.golang.org/grpc/status" 20 | 21 | pb "github.com/clastix/talos-csr-signer/pkg/proto" 22 | ) 23 | 24 | // Server is the struct satisfying the SecurityServiceServer interface. 25 | type Server struct { 26 | pb.UnimplementedSecurityServiceServer 27 | CACert []byte 28 | CAPrivateKey interface{} 29 | ValidToken string 30 | } 31 | 32 | // Certificate implements the SecurityService.Certificate RPC. 33 | // 34 | //nolint:wrapcheck 35 | func (s *Server) Certificate(ctx context.Context, req *pb.CertificateRequest) (*pb.CertificateResponse, error) { 36 | log.Printf("=== New Certificate Request Received ===") 37 | 38 | // Extract and validate token from metadata 39 | md, ok := metadata.FromIncomingContext(ctx) 40 | if !ok { 41 | log.Printf("ERROR: No metadata in request") 42 | 43 | return nil, status.Error(codes.Unauthenticated, "missing metadata") 44 | } 45 | 46 | log.Printf("Metadata extracted successfully") 47 | 48 | // Talos sends token directly in metadata "token" field, not as authorization header 49 | tokenHeader := md.Get("token") 50 | if len(tokenHeader) == 0 { 51 | log.Printf("ERROR: No token in metadata") 52 | log.Printf("Available metadata keys: %v", md) 53 | 54 | return nil, status.Error(codes.Unauthenticated, "missing token") 55 | } 56 | 57 | log.Printf("Token found in metadata") 58 | 59 | token := tokenHeader[0] 60 | log.Printf("Token prefix: %s...", token[:min(8, len(token))]) 61 | 62 | if token != s.ValidToken { 63 | log.Printf("ERROR: Invalid token received") 64 | log.Printf(" Received: %s...", token[:min(8, len(token))]) 65 | log.Printf(" Expected: %s...", s.ValidToken[:min(8, len(s.ValidToken))]) 66 | 67 | return nil, status.Error(codes.Unauthenticated, "invalid token") 68 | } 69 | 70 | log.Printf("Token validated successfully") 71 | 72 | // Parse the CSR 73 | log.Printf("Parsing CSR (length: %d bytes)", len(req.GetCsr())) 74 | 75 | block, _ := pem.Decode(req.GetCsr()) 76 | if block == nil { 77 | log.Printf("ERROR: Failed to decode PEM CSR") 78 | 79 | return nil, status.Error(codes.InvalidArgument, "failed to decode PEM CSR") 80 | } 81 | 82 | log.Printf("CSR PEM decoded successfully") 83 | 84 | csr, err := x509.ParseCertificateRequest(block.Bytes) 85 | if err != nil { 86 | log.Printf("ERROR: Failed to parse CSR: %v", err) 87 | 88 | return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("failed to parse CSR: %v", err)) 89 | } 90 | 91 | log.Printf("CSR parsed successfully") 92 | 93 | // Verify CSR signature 94 | if err := csr.CheckSignature(); err != nil { 95 | log.Printf("ERROR: Invalid CSR signature: %v", err) 96 | 97 | return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("invalid CSR signature: %v", err)) 98 | } 99 | 100 | log.Printf("CSR signature verified") 101 | 102 | log.Printf("CSR Details: Subject=%s, DNSNames=%v, IPAddresses=%v", 103 | csr.Subject.CommonName, csr.DNSNames, csr.IPAddresses) 104 | 105 | // Parse CA certificate 106 | caBlock, _ := pem.Decode(s.CACert) 107 | if caBlock == nil { 108 | return nil, status.Error(codes.Internal, "failed to decode CA certificate") 109 | } 110 | 111 | caCert, err := x509.ParseCertificate(caBlock.Bytes) 112 | if err != nil { 113 | return nil, status.Error(codes.Internal, fmt.Sprintf("failed to parse CA cert: %v", err)) 114 | } 115 | 116 | // Create certificate template 117 | serialNumber, err := generateSerialNumber() 118 | if err != nil { 119 | return nil, status.Error(codes.Internal, fmt.Sprintf("failed to generate serial: %v", err)) 120 | } 121 | 122 | template := &x509.Certificate{ 123 | SerialNumber: serialNumber, 124 | Subject: csr.Subject, 125 | NotBefore: time.Now(), 126 | NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 year validity 127 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, 128 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 129 | BasicConstraintsValid: true, 130 | DNSNames: csr.DNSNames, 131 | IPAddresses: csr.IPAddresses, 132 | } 133 | 134 | // Sign the certificate 135 | certDER, err := x509.CreateCertificate(nil, template, caCert, csr.PublicKey, s.CAPrivateKey) 136 | if err != nil { 137 | return nil, status.Error(codes.Internal, fmt.Sprintf("failed to create certificate: %v", err)) 138 | } 139 | 140 | // Encode signed certificate to PEM 141 | certPEM := pem.EncodeToMemory(&pem.Block{ 142 | Type: "CERTIFICATE", 143 | Bytes: certDER, 144 | }) 145 | 146 | log.Printf("✓ Certificate signed successfully for: %s (valid until: %s)", 147 | csr.Subject.CommonName, template.NotAfter.Format(time.RFC3339)) 148 | log.Printf("=== Certificate Request Completed Successfully ===") 149 | 150 | return &pb.CertificateResponse{ 151 | Ca: s.CACert, 152 | Crt: certPEM, 153 | }, nil 154 | } 155 | 156 | func generateSerialNumber() (*big.Int, error) { 157 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 158 | 159 | return rand.Int(rand.Reader, serialNumberLimit) //nolint:wrapcheck 160 | } 161 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Kamaji addon for Talos worker nodes - Makefile 2 | # Build, test, and container image automation 3 | # 4 | # NOTE: This Makefile is for development and building container images. 5 | # For deployment instructions, see: 6 | # - docs/sidecar-deployment.md (Kamaji) 7 | # - docs/standalone-deployment.md (kubeadm) 8 | 9 | GIT_HEAD_COMMIT ?= $$(git rev-parse --short HEAD) 10 | VERSION ?= $(or $(shell git describe --abbrev=0 --tags 2>/dev/null),$(GIT_HEAD_COMMIT)) 11 | 12 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 13 | ifeq (,$(shell go env GOBIN)) 14 | GOBIN=$(shell go env GOPATH)/bin 15 | else 16 | GOBIN=$(shell go env GOBIN) 17 | endif 18 | 19 | # Setting SHELL to bash allows bash commands to be executed by recipes. 20 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 21 | SHELL = /usr/bin/env bash -o pipefail 22 | .SHELLFLAGS = -ec 23 | 24 | ## Location to install dependencies to 25 | LOCALBIN ?= $(shell pwd)/bin 26 | $(LOCALBIN): 27 | mkdir -p $(LOCALBIN) 28 | 29 | ## Tool Binaries 30 | GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint 31 | KO ?= $(LOCALBIN)/ko 32 | PROTOC ?= $(LOCALBIN)/protoc 33 | PROTOC_GEN_GO ?= $(LOCALBIN)/protoc-gen-go 34 | PROTOC_GEN_GO_GRCP ?= $(LOCALBIN)/protoc-gen-go-grpc 35 | 36 | # OCI variables 37 | OCI_REGISTRY ?= ghcr.io 38 | OCI_REPO ?= clastix/talos-csr-signer 39 | OCI_TAG ?= latest 40 | OCI_NAME = $(OCI_REGISTRY)/$(OCI_REPO):$(OCI_TAG) 41 | 42 | # Binary name 43 | BINARY_NAME = talos-csr-signer 44 | BINARY_PATH = bin/$(BINARY_NAME) 45 | 46 | # Protobuf 47 | PROTO_DIR = pkg/proto 48 | PROTO_FILES = $(PROTO_DIR)/security.proto 49 | PROTO_GEN = $(PROTO_DIR)/security.pb.go $(PROTO_DIR)/security_grpc.pb.go 50 | PROTOC_VERSION := 28.2 51 | PROTOC_GEN_GO_VERSION := 1.27.1 52 | PROTOC_GEN_GO_GRCP_VERSION := 1.5.1 53 | 54 | # Default target - show help 55 | .DEFAULT_GOAL := help 56 | 57 | all: build 58 | 59 | ##@ Binary 60 | 61 | .PHONY: ko 62 | ko: $(KO) ## Download ko locally if necessary. 63 | $(KO): $(LOCALBIN) 64 | test -s $(LOCALBIN)/ko || GOBIN=$(LOCALBIN) CGO_ENABLED=0 go install -ldflags="-s -w" github.com/google/ko@v0.18.0 65 | 66 | .PHONY: protoc_gen_go_grpc 67 | protoc_gen_go_grpc: $(PROTOC_GEN_GO_GRCP) ## Download protoc-gen-go-grpc locally if necessary. 68 | $(PROTOC_GEN_GO_GRCP): $(LOCALBIN) 69 | test -s $(LOCALBIN)/protoc-gen-go-grpc || GOBIN=$(LOCALBIN) CGO_ENABLED=0 go install -ldflags="-s -w" google.golang.org/grpc/cmd/protoc-gen-go-grpc@v$(PROTOC_GEN_GO_GRCP_VERSION) 70 | 71 | .PHONY: protoc_gen_go 72 | protoc_gen_go: $(PROTOC_GEN_GO) ## Download protoc-gen-go locally if necessary. 73 | $(PROTOC_GEN_GO): $(LOCALBIN) 74 | test -s $(LOCALBIN)/protoc-gen-go || GOBIN=$(LOCALBIN) CGO_ENABLED=0 go install -ldflags="-s -w" google.golang.org/protobuf/cmd/protoc-gen-go@v$(PROTOC_GEN_GO_VERSION) 75 | 76 | .PHONY: protoc 77 | protoc: $(PROTOC) ## Download protoc locally if necessary. 78 | $(PROTOC): $(LOCALBIN) 79 | test -s $(PROTOC) || (rm -f $(PROTOC) && \ 80 | curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v$(PROTOC_VERSION)/protoc-$(PROTOC_VERSION)-linux-x86_64.zip && \ 81 | unzip -j protoc-*.zip bin/protoc -d bin && \ 82 | rm protoc-*.zip) 83 | 84 | .PHONY: golangci-lint 85 | golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. 86 | $(GOLANGCI_LINT): $(LOCALBIN) 87 | test -s $(LOCALBIN)/golangci-lint || GOBIN=$(LOCALBIN) CGO_ENABLED=0 go install -ldflags="-s -w" github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.0.2 88 | 89 | ##@ General 90 | 91 | help: ## Display this help message 92 | @echo "Kamaji Talos Addon - Available Targets" 93 | @echo "" 94 | @awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make $(COLOR_BLUE)\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " $(COLOR_BLUE)%-20s %s\n", $$1, $$2 } /^##@/ { printf "\n%s\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 95 | 96 | ##@ Development 97 | 98 | proto: protoc protoc_gen_go protoc_gen_go_grpc ## Generate protobuf code 99 | PATH=$$PATH:$(LOCALBIN) $(PROTOC) --go_out=. --go_opt=paths=source_relative \ 100 | --go-grpc_out=. --go-grpc_opt=paths=source_relative \ 101 | $(PROTO_FILES) 102 | 103 | deps: ## Download Go module dependencies 104 | go mod download 105 | go mod tidy 106 | 107 | build: pkg/proto ## Build the binary locally 108 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o $(BINARY_PATH) . 109 | 110 | test: ## Run unit tests 111 | go test -v -race -coverprofile=coverage.out ./... 112 | 113 | lint: golangci-lint ## Run golangci-lint (requires golangci-lint installed) 114 | $(GOLANGCI_LINT) run -c=.golangci.yaml ./... 115 | 116 | clean: ## Clean generated files and binaries 117 | @rm -f $(PROTO_GEN) 118 | @rm -rf bin/ 119 | @rm -f coverage.out 120 | @go clean 121 | 122 | ##@ OCI 123 | 124 | KO_LOCAL ?= true 125 | KO_PUSH ?= false 126 | 127 | oci-build: $(KO) ## Build OCI artefact 128 | KOCACHE=/tmp/ko-cache KO_DOCKER_REPO=${OCI_REGISTRY}/${OCI_REPO} \ 129 | $(KO) build . --bare --sbom=none --tags=$(VERSION) --local=$(KO_LOCAL) --push=$(KO_PUSH) 130 | 131 | oci-run: oci-build ## Run OCI container locally (for testing) 132 | @docker run --rm -it \ 133 | -p 50001:50001 \ 134 | -e CA_CERT_PATH=/tmp/ca.crt \ 135 | -e CA_KEY_PATH=/tmp/ca.key \ 136 | -e TALOS_TOKEN=test-token \ 137 | $(OCI_NAME) 138 | 139 | ##@ Information 140 | 141 | version: ## Show version information 142 | @echo "$(COLOR_BOLD)Talos CSR Signer$(COLOR_RESET)" 143 | @echo "Image: $(IMAGE_NAME)" 144 | 145 | env: ## Show environment variables 146 | @echo "$(COLOR_BOLD)Environment:$(COLOR_RESET)" 147 | @echo "IMAGE_REGISTRY = $(IMAGE_REGISTRY)" 148 | @echo "IMAGE_REPO = $(IMAGE_REPO)" 149 | @echo "IMAGE_TAG = $(IMAGE_TAG)" 150 | @echo "IMAGE_NAME = $(IMAGE_NAME)" 151 | -------------------------------------------------------------------------------- /pkg/proto/security_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | // 5 | // This file is derived from: 6 | // https://github.com/siderolabs/talos/blob/main/api/security/security.proto 7 | // Copyright (c) 2024 Sidero Labs, Inc. 8 | 9 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 10 | // versions: 11 | // - protoc-gen-go-grpc v1.5.1 12 | // - protoc v5.28.2 13 | // source: pkg/proto/security.proto 14 | 15 | package securityapi 16 | 17 | import ( 18 | context "context" 19 | grpc "google.golang.org/grpc" 20 | codes "google.golang.org/grpc/codes" 21 | status "google.golang.org/grpc/status" 22 | ) 23 | 24 | // This is a compile-time assertion to ensure that this generated file 25 | // is compatible with the grpc package it is being compiled against. 26 | // Requires gRPC-Go v1.64.0 or later. 27 | const _ = grpc.SupportPackageIsVersion9 28 | 29 | const ( 30 | SecurityService_Certificate_FullMethodName = "/securityapi.SecurityService/Certificate" 31 | ) 32 | 33 | // SecurityServiceClient is the client API for SecurityService service. 34 | // 35 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 36 | // 37 | // SecurityService provides certificate signing for Talos workers 38 | type SecurityServiceClient interface { 39 | Certificate(ctx context.Context, in *CertificateRequest, opts ...grpc.CallOption) (*CertificateResponse, error) 40 | } 41 | 42 | type securityServiceClient struct { 43 | cc grpc.ClientConnInterface 44 | } 45 | 46 | func NewSecurityServiceClient(cc grpc.ClientConnInterface) SecurityServiceClient { 47 | return &securityServiceClient{cc} 48 | } 49 | 50 | func (c *securityServiceClient) Certificate(ctx context.Context, in *CertificateRequest, opts ...grpc.CallOption) (*CertificateResponse, error) { 51 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 52 | out := new(CertificateResponse) 53 | err := c.cc.Invoke(ctx, SecurityService_Certificate_FullMethodName, in, out, cOpts...) 54 | if err != nil { 55 | return nil, err 56 | } 57 | return out, nil 58 | } 59 | 60 | // SecurityServiceServer is the server API for SecurityService service. 61 | // All implementations must embed UnimplementedSecurityServiceServer 62 | // for forward compatibility. 63 | // 64 | // SecurityService provides certificate signing for Talos workers 65 | type SecurityServiceServer interface { 66 | Certificate(context.Context, *CertificateRequest) (*CertificateResponse, error) 67 | mustEmbedUnimplementedSecurityServiceServer() 68 | } 69 | 70 | // UnimplementedSecurityServiceServer must be embedded to have 71 | // forward compatible implementations. 72 | // 73 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 74 | // pointer dereference when methods are called. 75 | type UnimplementedSecurityServiceServer struct{} 76 | 77 | func (UnimplementedSecurityServiceServer) Certificate(context.Context, *CertificateRequest) (*CertificateResponse, error) { 78 | return nil, status.Errorf(codes.Unimplemented, "method Certificate not implemented") 79 | } 80 | func (UnimplementedSecurityServiceServer) mustEmbedUnimplementedSecurityServiceServer() {} 81 | func (UnimplementedSecurityServiceServer) testEmbeddedByValue() {} 82 | 83 | // UnsafeSecurityServiceServer may be embedded to opt out of forward compatibility for this service. 84 | // Use of this interface is not recommended, as added methods to SecurityServiceServer will 85 | // result in compilation errors. 86 | type UnsafeSecurityServiceServer interface { 87 | mustEmbedUnimplementedSecurityServiceServer() 88 | } 89 | 90 | func RegisterSecurityServiceServer(s grpc.ServiceRegistrar, srv SecurityServiceServer) { 91 | // If the following call pancis, it indicates UnimplementedSecurityServiceServer was 92 | // embedded by pointer and is nil. This will cause panics if an 93 | // unimplemented method is ever invoked, so we test this at initialization 94 | // time to prevent it from happening at runtime later due to I/O. 95 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 96 | t.testEmbeddedByValue() 97 | } 98 | s.RegisterService(&SecurityService_ServiceDesc, srv) 99 | } 100 | 101 | func _SecurityService_Certificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 102 | in := new(CertificateRequest) 103 | if err := dec(in); err != nil { 104 | return nil, err 105 | } 106 | if interceptor == nil { 107 | return srv.(SecurityServiceServer).Certificate(ctx, in) 108 | } 109 | info := &grpc.UnaryServerInfo{ 110 | Server: srv, 111 | FullMethod: SecurityService_Certificate_FullMethodName, 112 | } 113 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 114 | return srv.(SecurityServiceServer).Certificate(ctx, req.(*CertificateRequest)) 115 | } 116 | return interceptor(ctx, in, info, handler) 117 | } 118 | 119 | // SecurityService_ServiceDesc is the grpc.ServiceDesc for SecurityService service. 120 | // It's only intended for direct use with grpc.RegisterService, 121 | // and not to be introspected or modified (even as a copy) 122 | var SecurityService_ServiceDesc = grpc.ServiceDesc{ 123 | ServiceName: "securityapi.SecurityService", 124 | HandlerType: (*SecurityServiceServer)(nil), 125 | Methods: []grpc.MethodDesc{ 126 | { 127 | MethodName: "Certificate", 128 | Handler: _SecurityService_Certificate_Handler, 129 | }, 130 | }, 131 | Streams: []grpc.StreamDesc{}, 132 | Metadata: "pkg/proto/security.proto", 133 | } 134 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Clastix Labs 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // Run the gRPC Server as a CLI binary. 5 | package main 6 | 7 | import ( 8 | "context" 9 | "crypto/tls" 10 | "crypto/x509" 11 | "encoding/pem" 12 | "fmt" 13 | "log" 14 | "net" 15 | "os" 16 | "os/signal" 17 | "syscall" 18 | 19 | "github.com/pkg/errors" 20 | "github.com/spf13/cobra" 21 | "github.com/spf13/viper" 22 | "google.golang.org/grpc" 23 | "google.golang.org/grpc/credentials" 24 | 25 | pkgerrors "github.com/clastix/talos-csr-signer/pkg/errors" 26 | pb "github.com/clastix/talos-csr-signer/pkg/proto" 27 | "github.com/clastix/talos-csr-signer/pkg/server" 28 | ) 29 | 30 | const ( 31 | cliPortName = "port" 32 | cliCACertificatePath = "ca-cert-path" 33 | cliCAPrivateKeyPath = "ca-key-path" 34 | cliTLSCertificatePath = "tls-cert-path" 35 | cliTLSPrivateKeyPath = "tls-key-path" 36 | cliTalosToken = "talos-token" 37 | ) 38 | 39 | func main() { 40 | rootCmd := &cobra.Command{ 41 | Use: "talos-csr-signer", 42 | Short: "gRPC server for signing Talos CSR", 43 | PreRunE: func(*cobra.Command, []string) error { 44 | switch { 45 | case viper.GetInt(cliPortName) <= 0: 46 | return pkgerrors.ErrMissingPort 47 | case viper.GetInt(cliPortName) > 65535: 48 | return pkgerrors.ErrPortOutOfRange 49 | case viper.GetString(cliTalosToken) == "": 50 | return pkgerrors.ErrMissingToken 51 | case viper.GetString(cliCACertificatePath) == "": 52 | return errors.Wrap(pkgerrors.ErrMissingPath, "CA certificate path is missing") 53 | case viper.GetString(cliCAPrivateKeyPath) == "": 54 | return errors.Wrap(pkgerrors.ErrMissingPath, "CA private key path is missing") 55 | case viper.GetString(cliTLSCertificatePath) == "": 56 | return errors.Wrap(pkgerrors.ErrMissingPath, "server certificate path is missing") 57 | case viper.GetString(cliTLSPrivateKeyPath) == "": 58 | return errors.Wrap(pkgerrors.ErrMissingPath, "server private key path is missing") 59 | } 60 | 61 | return nil 62 | }, 63 | RunE: func(*cobra.Command, []string) error { 64 | // Load CA certificate 65 | caCertPEM, caCertErr := os.ReadFile(viper.GetString(cliCACertificatePath)) 66 | if caCertErr != nil { 67 | return errors.Wrap(pkgerrors.ErrReadFile, "failed to read CA certificate: "+caCertErr.Error()) 68 | } 69 | // Load CA private key 70 | caKeyPEM, caKeyErr := os.ReadFile(viper.GetString(cliCAPrivateKeyPath)) 71 | if caKeyErr != nil { 72 | return errors.Wrap(pkgerrors.ErrReadFile, "failed to read CA private key: "+caKeyErr.Error()) 73 | } 74 | // Parse CA private key 75 | block, _ := pem.Decode(caKeyPEM) 76 | if block == nil { 77 | return pkgerrors.ErrPemDecoding 78 | } 79 | 80 | var caPrivateKey interface{} 81 | var privateKeyErr error 82 | 83 | switch block.Type { 84 | case "ED25519 PRIVATE KEY": 85 | caPrivateKey, privateKeyErr = x509.ParsePKCS8PrivateKey(block.Bytes) 86 | case "EC PRIVATE KEY": 87 | caPrivateKey, privateKeyErr = x509.ParseECPrivateKey(block.Bytes) 88 | case "RSA PRIVATE KEY": 89 | caPrivateKey, privateKeyErr = x509.ParsePKCS1PrivateKey(block.Bytes) 90 | case "PRIVATE KEY": 91 | caPrivateKey, privateKeyErr = x509.ParsePKCS8PrivateKey(block.Bytes) 92 | default: 93 | return errors.Wrap(pkgerrors.ErrUnsupportedBlockType, block.Type) 94 | } 95 | 96 | if privateKeyErr != nil { 97 | return errors.Wrap(pkgerrors.ErrParseCertificate, privateKeyErr.Error()) 98 | } 99 | 100 | cert, crtErr := tls.LoadX509KeyPair(viper.GetString(cliTLSCertificatePath), viper.GetString(cliTLSPrivateKeyPath)) 101 | if crtErr != nil { 102 | return errors.Wrap(pkgerrors.ErrLoadingCertificate, crtErr.Error()) 103 | } 104 | // Create TLS credentials 105 | tlsConfig := &tls.Config{ //nolint:gosec 106 | Certificates: []tls.Certificate{cert}, 107 | ClientAuth: tls.NoClientCert, // Don't require client certificates 108 | } 109 | creds := credentials.NewTLS(tlsConfig) 110 | // Create gRPC Server with TLS 111 | srv := &server.Server{ 112 | CACert: caCertPEM, 113 | CAPrivateKey: caPrivateKey, 114 | ValidToken: viper.GetString(cliTalosToken), 115 | } 116 | 117 | port := viper.GetInt(cliPortName) 118 | lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) 119 | if err != nil { 120 | return errors.Wrap(pkgerrors.ErrServerListen, fmt.Sprintf("%d: %s", port, err.Error())) 121 | } 122 | 123 | grpcServer := grpc.NewServer(grpc.Creds(creds)) 124 | pb.RegisterSecurityServiceServer(grpcServer, srv) 125 | 126 | log.Printf("Talos CSR Signer listening on port %d with TLS enabled", port) 127 | 128 | if err = grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) { 129 | return errors.Wrap(pkgerrors.ErrGRPCServerServe, err.Error()) 130 | } 131 | 132 | return nil 133 | }, 134 | } 135 | 136 | // Flags with their defaults 137 | rootCmd.Flags().Int(cliPortName, 50001, "Port to listen on") 138 | rootCmd.Flags().String(cliCACertificatePath, "/etc/talos-ca/tls.crt", "Path to CA certificate") 139 | rootCmd.Flags().String(cliCAPrivateKeyPath, "/etc/talos-ca/tls.key", "Path to CA private key") 140 | rootCmd.Flags().String(cliTLSCertificatePath, "/etc/talos-server-crt/tls.crt", "Path to the Server TLS certificate") 141 | rootCmd.Flags().String(cliTLSPrivateKeyPath, "/etc/talos-server-crt/tls.key", "Path to Server TLS private key") 142 | rootCmd.Flags().String(cliTalosToken, "", "Talos token") 143 | // Bind flags to viper keys 144 | _ = viper.BindPFlag(cliPortName, rootCmd.Flags().Lookup(cliPortName)) 145 | _ = viper.BindPFlag(cliCACertificatePath, rootCmd.Flags().Lookup(cliCACertificatePath)) 146 | _ = viper.BindPFlag(cliCAPrivateKeyPath, rootCmd.Flags().Lookup(cliCAPrivateKeyPath)) 147 | _ = viper.BindPFlag(cliTLSCertificatePath, rootCmd.Flags().Lookup(cliTLSCertificatePath)) 148 | _ = viper.BindPFlag(cliTLSPrivateKeyPath, rootCmd.Flags().Lookup(cliTLSPrivateKeyPath)) 149 | _ = viper.BindPFlag(cliTalosToken, rootCmd.Flags().Lookup(cliTalosToken)) 150 | // Allow reading from env variables automatically. Env keys are uppercased and `.` replaced with `_`. 151 | viper.SetEnvPrefix("") 152 | viper.AutomaticEnv() 153 | // Explicit env key mapping (to allow different names if desired) 154 | _ = viper.BindEnv(cliPortName, "PORT") 155 | _ = viper.BindEnv(cliCACertificatePath, "CA_CERT_PATH") 156 | _ = viper.BindEnv(cliCAPrivateKeyPath, "CA_KEY_PATH") 157 | _ = viper.BindEnv(cliTLSCertificatePath, "TLS_CERT_PATH") 158 | _ = viper.BindEnv(cliTLSPrivateKeyPath, "TLS_KEY_PATH") 159 | _ = viper.BindEnv(cliTalosToken, "TALOS_TOKEN") 160 | 161 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 162 | defer stop() 163 | 164 | if err := rootCmd.ExecuteContext(ctx); err != nil { 165 | _, _ = fmt.Fprintln(os.Stderr, err) 166 | os.Exit(1) //nolint:gocritic 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /pkg/proto/security.pb.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | // 5 | // This file is derived from: 6 | // https://github.com/siderolabs/talos/blob/main/api/security/security.proto 7 | // Copyright (c) 2024 Sidero Labs, Inc. 8 | 9 | // Code generated by protoc-gen-go. DO NOT EDIT. 10 | // versions: 11 | // protoc-gen-go v1.27.1 12 | // protoc v5.28.2 13 | // source: pkg/proto/security.proto 14 | 15 | package securityapi 16 | 17 | import ( 18 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 19 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 20 | reflect "reflect" 21 | sync "sync" 22 | ) 23 | 24 | const ( 25 | // Verify that this generated code is sufficiently up-to-date. 26 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 27 | // Verify that runtime/protoimpl is sufficiently up-to-date. 28 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 29 | ) 30 | 31 | // CertificateRequest contains a PEM-encoded CSR 32 | type CertificateRequest struct { 33 | state protoimpl.MessageState 34 | sizeCache protoimpl.SizeCache 35 | unknownFields protoimpl.UnknownFields 36 | 37 | Csr []byte `protobuf:"bytes,1,opt,name=csr,proto3" json:"csr,omitempty"` 38 | } 39 | 40 | func (x *CertificateRequest) Reset() { 41 | *x = CertificateRequest{} 42 | if protoimpl.UnsafeEnabled { 43 | mi := &file_pkg_proto_security_proto_msgTypes[0] 44 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 45 | ms.StoreMessageInfo(mi) 46 | } 47 | } 48 | 49 | func (x *CertificateRequest) String() string { 50 | return protoimpl.X.MessageStringOf(x) 51 | } 52 | 53 | func (*CertificateRequest) ProtoMessage() {} 54 | 55 | func (x *CertificateRequest) ProtoReflect() protoreflect.Message { 56 | mi := &file_pkg_proto_security_proto_msgTypes[0] 57 | if protoimpl.UnsafeEnabled && x != nil { 58 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 59 | if ms.LoadMessageInfo() == nil { 60 | ms.StoreMessageInfo(mi) 61 | } 62 | return ms 63 | } 64 | return mi.MessageOf(x) 65 | } 66 | 67 | // Deprecated: Use CertificateRequest.ProtoReflect.Descriptor instead. 68 | func (*CertificateRequest) Descriptor() ([]byte, []int) { 69 | return file_pkg_proto_security_proto_rawDescGZIP(), []int{0} 70 | } 71 | 72 | func (x *CertificateRequest) GetCsr() []byte { 73 | if x != nil { 74 | return x.Csr 75 | } 76 | return nil 77 | } 78 | 79 | // CertificateResponse contains the CA cert and signed certificate 80 | type CertificateResponse struct { 81 | state protoimpl.MessageState 82 | sizeCache protoimpl.SizeCache 83 | unknownFields protoimpl.UnknownFields 84 | 85 | Ca []byte `protobuf:"bytes,1,opt,name=ca,proto3" json:"ca,omitempty"` // CA certificate in PEM format 86 | Crt []byte `protobuf:"bytes,2,opt,name=crt,proto3" json:"crt,omitempty"` // Signed certificate in PEM format 87 | } 88 | 89 | func (x *CertificateResponse) Reset() { 90 | *x = CertificateResponse{} 91 | if protoimpl.UnsafeEnabled { 92 | mi := &file_pkg_proto_security_proto_msgTypes[1] 93 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 94 | ms.StoreMessageInfo(mi) 95 | } 96 | } 97 | 98 | func (x *CertificateResponse) String() string { 99 | return protoimpl.X.MessageStringOf(x) 100 | } 101 | 102 | func (*CertificateResponse) ProtoMessage() {} 103 | 104 | func (x *CertificateResponse) ProtoReflect() protoreflect.Message { 105 | mi := &file_pkg_proto_security_proto_msgTypes[1] 106 | if protoimpl.UnsafeEnabled && x != nil { 107 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 108 | if ms.LoadMessageInfo() == nil { 109 | ms.StoreMessageInfo(mi) 110 | } 111 | return ms 112 | } 113 | return mi.MessageOf(x) 114 | } 115 | 116 | // Deprecated: Use CertificateResponse.ProtoReflect.Descriptor instead. 117 | func (*CertificateResponse) Descriptor() ([]byte, []int) { 118 | return file_pkg_proto_security_proto_rawDescGZIP(), []int{1} 119 | } 120 | 121 | func (x *CertificateResponse) GetCa() []byte { 122 | if x != nil { 123 | return x.Ca 124 | } 125 | return nil 126 | } 127 | 128 | func (x *CertificateResponse) GetCrt() []byte { 129 | if x != nil { 130 | return x.Crt 131 | } 132 | return nil 133 | } 134 | 135 | var File_pkg_proto_security_proto protoreflect.FileDescriptor 136 | 137 | var file_pkg_proto_security_proto_rawDesc = []byte{ 138 | 0x0a, 0x18, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x73, 0x65, 0x63, 0x75, 139 | 0x72, 0x69, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x73, 0x65, 0x63, 0x75, 140 | 0x72, 0x69, 0x74, 0x79, 0x61, 0x70, 0x69, 0x22, 0x26, 0x0a, 0x12, 0x43, 0x65, 0x72, 0x74, 0x69, 141 | 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 142 | 0x03, 0x63, 0x73, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x63, 0x73, 0x72, 0x22, 143 | 0x37, 0x0a, 0x13, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 144 | 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x63, 0x61, 0x18, 0x01, 0x20, 0x01, 145 | 0x28, 0x0c, 0x52, 0x02, 0x63, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x72, 0x74, 0x18, 0x02, 0x20, 146 | 0x01, 0x28, 0x0c, 0x52, 0x03, 0x63, 0x72, 0x74, 0x32, 0x63, 0x0a, 0x0f, 0x53, 0x65, 0x63, 0x75, 147 | 0x72, 0x69, 0x74, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x50, 0x0a, 0x0b, 0x43, 148 | 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x73, 0x65, 0x63, 149 | 0x75, 0x72, 0x69, 0x74, 0x79, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 150 | 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x73, 0x65, 151 | 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 152 | 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 153 | 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6c, 0x61, 0x73, 154 | 0x74, 0x69, 0x78, 0x2f, 0x74, 0x61, 0x6c, 0x6f, 0x73, 0x2d, 0x63, 0x73, 0x72, 0x2d, 0x73, 0x69, 155 | 0x67, 0x6e, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x73, 0x65, 0x63, 0x75, 0x72, 156 | 0x69, 0x74, 0x79, 0x61, 0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 157 | } 158 | 159 | var ( 160 | file_pkg_proto_security_proto_rawDescOnce sync.Once 161 | file_pkg_proto_security_proto_rawDescData = file_pkg_proto_security_proto_rawDesc 162 | ) 163 | 164 | func file_pkg_proto_security_proto_rawDescGZIP() []byte { 165 | file_pkg_proto_security_proto_rawDescOnce.Do(func() { 166 | file_pkg_proto_security_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_proto_security_proto_rawDescData) 167 | }) 168 | return file_pkg_proto_security_proto_rawDescData 169 | } 170 | 171 | var file_pkg_proto_security_proto_msgTypes = make([]protoimpl.MessageInfo, 2) 172 | var file_pkg_proto_security_proto_goTypes = []interface{}{ 173 | (*CertificateRequest)(nil), // 0: securityapi.CertificateRequest 174 | (*CertificateResponse)(nil), // 1: securityapi.CertificateResponse 175 | } 176 | var file_pkg_proto_security_proto_depIdxs = []int32{ 177 | 0, // 0: securityapi.SecurityService.Certificate:input_type -> securityapi.CertificateRequest 178 | 1, // 1: securityapi.SecurityService.Certificate:output_type -> securityapi.CertificateResponse 179 | 1, // [1:2] is the sub-list for method output_type 180 | 0, // [0:1] is the sub-list for method input_type 181 | 0, // [0:0] is the sub-list for extension type_name 182 | 0, // [0:0] is the sub-list for extension extendee 183 | 0, // [0:0] is the sub-list for field type_name 184 | } 185 | 186 | func init() { file_pkg_proto_security_proto_init() } 187 | func file_pkg_proto_security_proto_init() { 188 | if File_pkg_proto_security_proto != nil { 189 | return 190 | } 191 | if !protoimpl.UnsafeEnabled { 192 | file_pkg_proto_security_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { 193 | switch v := v.(*CertificateRequest); i { 194 | case 0: 195 | return &v.state 196 | case 1: 197 | return &v.sizeCache 198 | case 2: 199 | return &v.unknownFields 200 | default: 201 | return nil 202 | } 203 | } 204 | file_pkg_proto_security_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { 205 | switch v := v.(*CertificateResponse); i { 206 | case 0: 207 | return &v.state 208 | case 1: 209 | return &v.sizeCache 210 | case 2: 211 | return &v.unknownFields 212 | default: 213 | return nil 214 | } 215 | } 216 | } 217 | type x struct{} 218 | out := protoimpl.TypeBuilder{ 219 | File: protoimpl.DescBuilder{ 220 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 221 | RawDescriptor: file_pkg_proto_security_proto_rawDesc, 222 | NumEnums: 0, 223 | NumMessages: 2, 224 | NumExtensions: 0, 225 | NumServices: 1, 226 | }, 227 | GoTypes: file_pkg_proto_security_proto_goTypes, 228 | DependencyIndexes: file_pkg_proto_security_proto_depIdxs, 229 | MessageInfos: file_pkg_proto_security_proto_msgTypes, 230 | }.Build() 231 | File_pkg_proto_security_proto = out.File 232 | file_pkg_proto_security_proto_rawDesc = nil 233 | file_pkg_proto_security_proto_goTypes = nil 234 | file_pkg_proto_security_proto_depIdxs = nil 235 | } 236 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 Clastix Labs. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /docs/standalone-deployment.md: -------------------------------------------------------------------------------- 1 | # Standalone Deployment Guide 2 | 3 | Deployment guide for running Talos CSR Signer on kubeadm control planes with Talos workers. 4 | 5 | ## Architecture 6 | 7 | CSR Signer runs as a DaemonSet on control plane nodes, exposed via HostPort 50001 on the control plane VIP. Compatible 8 | with keepalived, kube-vip, and external load balancers. 9 | 10 | ## Prerequisites 11 | 12 | - Kubernetes control plane installed with kubeadm 13 | - Control plane VIP via keepalived, kube-vip, or external LB 14 | - kubectl with cluster admin access 15 | - talosctl CLI 16 | - yq CLI 17 | - Talos worker nodes (bare metal, VMs, or cloud instances) 18 | 19 | ## Step 1: Configure Environment 20 | 21 | ```bash 22 | cp deploy/.env.example deploy/.env 23 | vi deploy/.env 24 | source deploy/.env 25 | ``` 26 | 27 | Example configuration: 28 | 29 | ```bash 30 | export CLUSTER_NAME="my-cluster" 31 | export NAMESPACE="default" 32 | export CONTROL_PLANE_IP="10.10.10.250" 33 | export KUBERNETES_VERSION="v1.33.0" 34 | export TALOS_VERSION="v1.8.3" 35 | export WORKER_IPS="192.168.11.102 192.168.11.103" 36 | 37 | # Registry for CSR Signer image 38 | export CSR_SIGNER_IMAGE="ghcr.io/clastix/talos-csr-signer" 39 | export CSR_SIGNER_IMAGE_TAG="latest" 40 | ``` 41 | 42 | ## Step 2: Generate Talos Secrets 43 | 44 | ```bash 45 | talosctl gen secrets -o secrets.yaml --force 46 | ``` 47 | 48 | ## Step 3: Prepare Credentials 49 | 50 | Extract Talos credentials and create Kubernetes secret: 51 | 52 | ```bash 53 | TALOS_CA_CRT=$(yq -r '.certs.os.crt' secrets.yaml | base64 -d) 54 | # Talos uses "BEGIN ED25519 PRIVATE KEY" but cert-manager requires "BEGIN PRIVATE KEY" (RFC 7468) 55 | TALOS_CA_KEY=$(yq -r '.certs.os.key' secrets.yaml | base64 -d | sed 's/ED25519 //g') 56 | TALOS_TOKEN=$(yq -r '.trustdinfo.token' secrets.yaml) 57 | 58 | kubectl create secret generic ${CLUSTER_NAME}-talos-ca -n $NAMESPACE \ 59 | --from-literal=tls.crt="$TALOS_CA_CRT" \ 60 | --from-literal=tls.key="$TALOS_CA_KEY" \ 61 | --from-literal=token="$TALOS_TOKEN" 62 | ``` 63 | 64 | ## Step 4: Generate the TLS Certificate with cert-manager 65 | 66 | The gRPC Server uses a TLS certificate generated by cert-manager. 67 | 68 | Create the CA Issuer: 69 | 70 | ```bash 71 | kubectl apply -f - < talos-csr-signer-daemonset.yaml < worker.yaml < -n ` for direct connection. Workers cannot forward Talos API requests in standalone deployments. 320 | 321 | --- 322 | 323 | ## Troubleshooting 324 | 325 | ### CSR Signer Not Starting 326 | 327 | ```bash 328 | kubectl logs -l app=talos-csr-signer -n ${NAMESPACE} 329 | ``` 330 | 331 | Common causes: 332 | 333 | - Missing secret: `kubectl get secret ${CLUSTER_NAME}-talos-ca -n ${NAMESPACE}` 334 | - Invalid CA format: Regenerate secret (Step 4) 335 | - HostPort conflict: Check port 50001 usage on control plane nodes 336 | 337 | ### Workers Cannot Reach CSR Signer 338 | 339 | ```bash 340 | kubectl get pods -l app=talos-csr-signer -n ${NAMESPACE} -o wide 341 | nc -zv $CONTROL_PLANE_IP 50001 342 | ``` 343 | 344 | Check: 345 | 346 | - Firewall rules on control plane nodes (port 50001) 347 | - VIP routing (keepalived, kube-vip) 348 | - Cert Manager certificate IPs match VIP 349 | 350 | ### Worker apid Not Starting 351 | 352 | ```bash 353 | talosctl --talosconfig=talosconfig -e -n logs apid 354 | ``` 355 | 356 | Common causes: 357 | 358 | - Discovery disabled: Verify `discovery.enabled: true` in worker.yaml 359 | - Token mismatch: Verify `machine.token` matches secret 360 | - Wrong Kubernetes token: Verify `cluster.token` is Kubernetes bootstrap token 361 | 362 | ### Workers Show NotReady 363 | 364 | Install CNI (Step 9) or verify existing CNI: 365 | 366 | ```bash 367 | kubectl get pods -n kube-flannel 368 | kubectl get pods -n kube-system -l k8s-app=calico-node 369 | ``` 370 | 371 | ### DaemonSet Not Scheduling 372 | 373 | ```bash 374 | kubectl get nodes --show-labels | grep control-plane 375 | kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.taints}{"\n"}{end}' 376 | ``` 377 | 378 | Verify node labels include `node-role.kubernetes.io/control-plane=` 379 | 380 | --- 381 | 382 | ## Configuration Reference 383 | 384 | ### Environment Variables 385 | 386 | | Variable | Default | Description | 387 | |-----------------|---------------------------------|-----------------------------------| 388 | | `PORT` | `50001` | gRPC server port | 389 | | `CA_CERT_PATH` | `/etc/talos-ca/tls.crt` | Talos Machine CA certificate path | 390 | | `CA_KEY_PATH` | `/etc/talos-ca/tls.key` | Talos Machine CA private key path | 391 | | `TLS_CERT_PATH` | `/etc/talos-server-crt/tls.crt` | CSR gRPC server certificate path | 392 | | `TLS_KEY_PATH` | `/etc/talos-server-crt/tls.key` | CSR gRPC server private key path | 393 | | `TALOS_TOKEN` | required | Machine token for authentication | 394 | 395 | --- 396 | 397 | ## References 398 | 399 | - [Talos Documentation](https://www.talos.dev) 400 | - [Kubernetes TLS Bootstrapping](https://kubernetes.io/docs/reference/access-authn-authz/bootstrap-tokens/) 401 | -------------------------------------------------------------------------------- /docs/sidecar-deployment.md: -------------------------------------------------------------------------------- 1 | # Sidecar Deployment Guide 2 | 3 | Deployment guide for running Talos CSR Signer as a sidecar in Kamaji TenantControlPlane with Talos workers. 4 | 5 | ## Architecture 6 | 7 | CSR Signer runs as a sidecar container in Kamaji TenantControlPlane, sharing the same LoadBalancer service on port 50001. Kamaji manages the control plane lifecycle. 8 | 9 | ## Prerequisites 10 | 11 | - Kubernetes cluster with Kamaji installed 12 | - kubectl with cluster admin access 13 | - talosctl CLI 14 | - yq CLI 15 | - Talos worker nodes (bare metal, VMs, or cloud instances) 16 | 17 | ## Step 1: Configure Environment 18 | 19 | ```bash 20 | cp deploy/.env.example deploy/.env 21 | vi deploy/.env 22 | source deploy/.env 23 | ``` 24 | 25 | Example configuration: 26 | 27 | ```bash 28 | export CLUSTER_NAME="my-cluster" 29 | export NAMESPACE="default" 30 | export CONTROL_PLANE_IP="10.10.10.100" 31 | export KUBERNETES_VERSION="v1.33.0" 32 | export TALOS_VERSION="v1.8.3" 33 | export WORKER_IPS="10.10.10.201 10.10.10.202 10.10.10.203" 34 | 35 | # Registry for CSR Signer image 36 | export CSR_SIGNER_IMAGE="ghcr.io/clastix/talos-csr-signer" 37 | export CSR_SIGNER_IMAGE_TAG="latest" 38 | ``` 39 | 40 | ## Step 2: Generate Talos Secrets 41 | 42 | ```bash 43 | talosctl gen secrets -o secrets.yaml --force 44 | ``` 45 | 46 | ## Step 3: Prepare Credentials 47 | 48 | Extract Talos credentials and create Kubernetes secret: 49 | 50 | ```bash 51 | TALOS_CA_CRT=$(yq -r '.certs.os.crt' secrets.yaml | base64 -d) 52 | # Talos uses "BEGIN ED25519 PRIVATE KEY" but cert-manager requires "BEGIN PRIVATE KEY" (RFC 7468) 53 | TALOS_CA_KEY=$(yq -r '.certs.os.key' secrets.yaml | base64 -d | sed 's/ED25519 //g') 54 | TALOS_TOKEN=$(yq -r '.trustdinfo.token' secrets.yaml) 55 | TALOS_CLUSTER_ID=$(yq -r '.cluster.id' secrets.yaml) 56 | TALOS_CLUSTER_SECRET=$(yq -r '.cluster.secret' secrets.yaml) 57 | 58 | kubectl create secret generic ${CLUSTER_NAME}-talos-ca -n $NAMESPACE \ 59 | --from-literal=tls.crt="$TALOS_CA_CRT" \ 60 | --from-literal=tls.key="$TALOS_CA_KEY" \ 61 | --from-literal=token="$TALOS_TOKEN" 62 | ``` 63 | 64 | ## Step 4: Generate the TLS Certificate with cert-manager 65 | 66 | The gRPC Server uses a TLS certificate generated by cert-manager, a dependency already required by Kamaji. 67 | 68 | Create the CA Issuer: 69 | 70 | ```bash 71 | kubectl apply -f - < ${NAMESPACE}-${CLUSTER_NAME}.kubeconfig 210 | 211 | K8S_CA=$(kubectl --kubeconfig=${NAMESPACE}-${CLUSTER_NAME}.kubeconfig config view --raw \ 212 | -o jsonpath='{.clusters[0].cluster.certificate-authority-data}') 213 | 214 | K8S_BOOTSTRAP_TOKEN=$(kubeadm --kubeconfig=${NAMESPACE}-${CLUSTER_NAME}.kubeconfig token create) 215 | 216 | # Re-extract credentials from secrets.yaml (keep base64 encoded for worker.yaml) 217 | TALOS_CA_CRT=$(yq -r '.certs.os.crt' secrets.yaml) 218 | TALOS_TOKEN=$(yq -r '.trustdinfo.token' secrets.yaml) 219 | TALOS_CLUSTER_ID=$(yq -r '.cluster.id' secrets.yaml) 220 | TALOS_CLUSTER_SECRET=$(yq -r '.cluster.secret' secrets.yaml) 221 | 222 | cat > worker.yaml < -n ` for direct connection. Workers cannot forward Talos API requests in Kamaji deployments. 332 | 333 | --- 334 | 335 | ## Troubleshooting 336 | 337 | ### Workers Not Joining 338 | 339 | ```bash 340 | kubectl logs -n $NAMESPACE -l kamaji.clastix.io/name=$CLUSTER_NAME -c talos-csr-signer 341 | ``` 342 | 343 | Common causes: 344 | - Token mismatch: Verify token in secret matches worker.yaml 345 | - Discovery disabled: Check `cluster.discovery.enabled: true` in worker.yaml 346 | - Port 50001 not accessible: Verify service has port 50001 exposed 347 | 348 | ### CSR Signer Not Starting 349 | 350 | ```bash 351 | kubectl describe pod -n $NAMESPACE -l kamaji.clastix.io/name=$CLUSTER_NAME 352 | ``` 353 | 354 | Common causes: 355 | - Secret not found: Verify secret exists in correct namespace 356 | - Invalid CA format: Ensure CA cert/key properly base64 decoded 357 | - Missing token: Check TALOS_TOKEN environment variable 358 | 359 | ### Token Validation Failing 360 | 361 | ```bash 362 | kubectl get secret ${CLUSTER_NAME}-talos-ca -n $NAMESPACE -o jsonpath='{.data.token}' | base64 -d 363 | yq -r '.machine.token' worker.yaml 364 | ``` 365 | 366 | Tokens must match exactly. 367 | 368 | --- 369 | 370 | ## Configuration Reference 371 | 372 | ### Environment Variables 373 | 374 | | Variable | Default | Description | 375 | |-----------------|---------|-----------------------------------| 376 | | `PORT` | `50001` | gRPC server port | 377 | | `CA_CERT_PATH` | `/etc/talos-ca/tls.crt` | Talos Machine CA certificate path | 378 | | `CA_KEY_PATH` | `/etc/talos-ca/tls.key` | Talos Machine CA private key path | 379 | | `TLS_CERT_PATH` | `/etc/talos-server-crt/tls.crt` | CSR gRPC server certificate path | 380 | | `TLS_KEY_PATH` | `/etc/talos-server-crt/tls.key` | CSR gRPC server private key path | 381 | | `TALOS_TOKEN` | required | Machine token for authentication | 382 | 383 | --- 384 | 385 | ## References 386 | 387 | - [Kamaji Documentation](https://kamaji.clastix.io) 388 | - [Talos Documentation](https://www.talos.dev) 389 | - [Kubernetes TLS Bootstrapping](https://kubernetes.io/docs/reference/access-authn-authz/bootstrap-tokens/) 390 | 391 | --- 392 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Talos CSR Signer 2 | 3 | A standalone gRPC service that implements the Talos Security Service protocol, enabling Talos worker nodes to obtain certificates and function with non-Talos control planes. 4 | 5 | > Warning: This project is still experimental and in active development. Features and instructions may change. 6 | 7 | _tl;dr;_ ▶️ watch the [live action demo](https://youtu.be/nSGo_72LnmY) of the project. 8 | 9 | ## Overview 10 | 11 | Talos CSR Signer bridges the gap between traditional Kubernetes control planes (kubeadm, Kamaji) and Talos Linux worker nodes. It provides the certificate signing functionality that Talos workers expect from a native Talos control plane. 12 | 13 | ### The Problem 14 | 15 | Talos Linux worker nodes require two separate PKI systems to function: 16 | 17 | 1. **Kubernetes PKI** - For kubelet to join the Kubernetes cluster (port 6443) 18 | 2. **Talos Machine PKI** - For the Talos API (apid) to enable node management (port 50000/50001) 19 | 20 | In hybrid deployments where the control plane is not running Talos Linux: 21 | - ✅ Kubernetes functionality works - kubelet joins successfully using standard bootstrap tokens 22 | - ❌ Talos API remains unavailable - workers cannot obtain Talos certificates 23 | - ❌ No `talosctl` access - cannot view logs, upgrade nodes, or access console 24 | 25 | Talos workers expect a `trustd` service (part of Talos Linux) to sign Talos API certificates. Traditional control planes don't provide this service. 26 | 27 | ### The Solution 28 | 29 | This service implements the same gRPC protocol as Talos's native `trustd`, acting as a certificate authority for the Talos Machine PKI. It runs as a standard Kubernetes workload alongside your control plane, providing certificate signing services to Talos workers. 30 | 31 | ## How It Works 32 | 33 | ### Dual PKI Architecture 34 | 35 | Talos worker nodes operate with two independent PKI systems: 36 | 37 | ``` 38 | ┌────────────────────────────────────────────────────┐ 39 | │ Control Plane: 10.10.10.101 │ 40 | │ │ 41 | │ Port 6443 → kube-apiserver │ 42 | │ Issues: Kubernetes certificates │ 43 | │ Used by: kubelet │ 44 | │ │ 45 | │ Port 50001 → talos-csr-signer │ 46 | │ Issues: Talos Machine certificates │ 47 | │ Used by: apid (Talos API) │ 48 | │ │ 49 | └─────────────┬────────────────┬─────────────────────┘ 50 | │ │ 51 | │ │ 52 | ┌───────▼────────────────▼──────┐ 53 | │ Talos Worker │ 54 | │ │ 55 | │ kubelet → 6443 (K8s certs) │ 56 | │ apid → 50001 (Talos certs) │ 57 | └───────────────────────────────┘ 58 | ``` 59 | 60 | ### Certificate Signing Flow 61 | 62 | When a Talos worker starts, it requests certificates for its API service (apid): 63 | 64 | ``` 65 | Talos Worker Talos CSR Signer 66 | | | 67 | | 1. Generate CSR | 68 | | (subject, IPs) | 69 | | | 70 | | 2. gRPC: Certificate() | 71 | | + metadata: token | 72 | |────────────────────────────────>| 73 | | | 74 | | 3. Validate token | 75 | | 4. Sign with CA | 76 | | | 77 | | 5. CertificateResponse | 78 | | ca: | 79 | | crt: | 80 | |<────────────────────────────────| 81 | | | 82 | | 6. Start apid with cert | 83 | | | 84 | ``` 85 | 86 | ### Discovery and Connection 87 | 88 | Workers locate the CSR Signer using the same discovery mechanism as Talos's native `trustd`: 89 | 90 | 1. **Control Plane Endpoint**: Workers are configured with the control plane IP (e.g., `https://10.10.10.100:6443`) 91 | 2. **Port Translation**: Workers contact port 50001 on the same IP for certificate signing 92 | 3. **Automatic Failover**: In HA deployments, LoadBalancer routes requests to healthy CSR Signer pods 93 | 94 | ### Security Model 95 | 96 | The CSR Signer uses the same authentication model as Talos's native `trustd`: 97 | 98 | - **Shared Secret**: Single machine token for all workers in the cluster 99 | - **Token Authentication**: Requests validated via gRPC metadata 100 | - **TLS Encryption**: All communication encrypted in transit 101 | - **CA Private Key**: Stored in Kubernetes Secret, mounted read-only 102 | 103 | This is an intentional design inherited from Talos Linux. 104 | 105 | ## Deployment Models 106 | 107 | ### Sidecar Deployment (Kamaji) 108 | 109 | Run CSR Signer as a sidecar container in Kamaji TenantControlPlane, sharing the same LoadBalancer IP on port 50001: 110 | 111 | ```yaml 112 | apiVersion: kamaji.clastix.io/v1alpha1 113 | kind: TenantControlPlane 114 | spec: 115 | controlPlane: 116 | deployment: 117 | additionalContainers: 118 | - name: talos-csr-signer 119 | image: ghcr.io/clastix/talos-csr-signer:latest 120 | ports: 121 | - containerPort: 50001 122 | env: 123 | - name: TALOS_TOKEN 124 | valueFrom: 125 | secretKeyRef: 126 | name: cluster-talos-ca 127 | key: token 128 | volumeMounts: 129 | - name: talos-ca 130 | mountPath: /etc/talos-ca 131 | readOnly: true 132 | - name: tls-cert 133 | mountPath: /etc/talos-server-crt 134 | readOnly: true 135 | additionalVolumes: 136 | - name: talos-ca 137 | secret: 138 | secretName: cluster-talos-ca 139 | - name: tls-cert 140 | secret: 141 | secretName: cluster-talos-tls-cert 142 | service: 143 | additionalPorts: 144 | - name: talos-csr-signer 145 | port: 50001 146 | targetPort: 50001 147 | ``` 148 | 149 | Use when: 150 | 151 | - Running Kamaji for multi-tenant Kubernetes 152 | - Each tenant needs isolated Talos worker support 153 | - Control planes are dynamically provisioned 154 | 155 | See [docs/sidecar-deployment.md](docs/sidecar-deployment.md) for complete guide. 156 | 157 | ### Standalone Deployment (kubeadm) 158 | 159 | Run CSR Signer as a DaemonSet on control plane nodes, exposed via HostPort 50001: 160 | 161 | ```yaml 162 | apiVersion: apps/v1 163 | kind: DaemonSet 164 | spec: 165 | template: 166 | spec: 167 | nodeSelector: 168 | node-role.kubernetes.io/control-plane: "" 169 | containers: 170 | - name: talos-csr-signer 171 | image: ghcr.io/clastix/talos-csr-signer:latest 172 | ports: 173 | - containerPort: 50001 174 | hostPort: 50001 175 | env: 176 | - name: TALOS_TOKEN 177 | valueFrom: 178 | secretKeyRef: 179 | name: cluster-talos-ca 180 | key: token 181 | volumeMounts: 182 | - name: talos-ca 183 | mountPath: /etc/talos-ca 184 | readOnly: true 185 | - name: tls-cert 186 | mountPath: /etc/talos-server-crt 187 | readOnly: true 188 | volumes: 189 | - name: talos-ca 190 | secret: 191 | secretName: cluster-talos-ca 192 | - name: tls-cert 193 | secret: 194 | secretName: cluster-talos-tls-cert 195 | ``` 196 | 197 | Use when: 198 | 199 | - Existing kubeadm control plane with VIP (keepalived, kube-vip) 200 | - Want to add Talos workers to existing clusters 201 | 202 | See [docs/standalone-deployment.md](docs/standalone-deployment.md) for complete guide. 203 | 204 | ## Use Cases 205 | 206 | ### When to Use CSR Signer 207 | 208 | **Multi-Tenant Kubernetes:** 209 | - Kamaji provides virtualized control planes 210 | - Each tenant gets isolated Talos workers machines 211 | - Separate Machine PKI per tenant 212 | 213 | **Cost Optimization:** 214 | - Control plane: Managed Kubernetes (convenience, support) 215 | - Workers: Self-managed Talos (cost-effective, secure) 216 | 217 | ### When NOT to Use CSR Signer 218 | 219 | If you're deploying a pure Talos Linux, use the native `trustd` service that comes with Talos control planes. 220 | 221 | ## Configuration 222 | 223 | The service is configured through environment variables: 224 | 225 | | Variable | Default | Description | 226 | |----------|---------|-------------| 227 | | `PORT` | `50001` | gRPC server port | 228 | | `CA_CERT_PATH` | `/etc/talos-ca/tls.crt` | Talos Machine CA certificate path | 229 | | `CA_KEY_PATH` | `/etc/talos-ca/tls.key` | Talos Machine CA private key path | 230 | | `TLS_CERT_PATH` | `/etc/talos-server-crt/tls.crt` | CSR gRPC server certificate path | 231 | | `TLS_KEY_PATH` | `/etc/talos-server-crt/tls.key` | CSR gRPC server private key path | 232 | | `TALOS_TOKEN` | *(required)* | Machine token for authentication | 233 | 234 | ### Prerequisites 235 | 236 | - **cert-manager**: Required to generate TLS certificates for the gRPC server 237 | - **Talos secrets**: Generated using `talosctl gen secrets` 238 | 239 | The Talos Machine CA certificate, private key, and token are stored in a Kubernetes Secret. The gRPC server TLS certificate is generated by cert-manager using the Talos Machine CA as the issuer. 240 | 241 | Talos uses ED25519 keys with non-RFC-7468-compliant PEM labels (`BEGIN ED25519 PRIVATE KEY`). The `cert-manager` requires RFC 7468 format (`BEGIN PRIVATE KEY`). The deployment guides include a simple `sed` workaround to fix the PEM label without modifying the actual key bytes. 242 | 243 | ## Development 244 | 245 | Build and test workflow: 246 | 247 | ```bash 248 | # Install dependencies and generate protobuf code 249 | make deps && make proto 250 | 251 | # Build binary 252 | make build 253 | 254 | # Run tests 255 | make test 256 | make lint 257 | 258 | # Build container image with ko (default) 259 | make docker-build 260 | 261 | # Build container image with Docker 262 | docker build -t docker.io/bsctl/talos-csr-signer:latest . 263 | docker push docker.io/bsctl/talos-csr-signer:latest 264 | ``` 265 | 266 | Available Makefile targets: 267 | 268 | ```bash 269 | make help # Show all available targets 270 | 271 | # Development 272 | make proto # Generate protobuf code 273 | make deps # Download Go module dependencies 274 | make build # Build binary locally 275 | make test # Run unit tests 276 | make lint # Run golangci-lint 277 | 278 | # Container Images 279 | make oci-build # Build OCI image with ko 280 | make oci-run # Run container locally (testing) 281 | 282 | # Utilities 283 | make clean # Clean generated files 284 | make version # Show version information 285 | make env # Show environment variables 286 | ``` 287 | 288 | For deployment instructions, see the deployment guides in [docs/](docs/). 289 | 290 | ## Contributing 291 | 292 | Contributions welcome! Please: 293 | 294 | 1. Fork the repository 295 | 2. Create a feature branch 296 | 3. Make your changes with tests 297 | 4. Submit a pull request 298 | 299 | ## License 300 | 301 | ### Apache License 2.0 302 | 303 | This project is licensed under the **Apache License 2.0**. See the [LICENSE](LICENSE) file for full details. 304 | 305 | ### Protocol Buffer Definition - Mozilla Public License 2.0 306 | 307 | The protocol buffer definition (`proto/security.proto`) is derived from the [Talos Linux project](https://github.com/siderolabs/talos) and is licensed under the **Mozilla Public License 2.0**. See the [LICENSE-MPL-2.0](https://www.mozilla.org/en-US/MPL/2.0/) file for full details. 308 | 309 | This file implements the Talos gRPC protocol specification to ensure compatibility with Talos worker nodes. 310 | 311 | ### Attribution 312 | 313 | This project implements the Talos gRPC protocol as defined by: 314 | 315 | - **Talos Linux:** https://github.com/siderolabs/talos 316 | - **Protocol Definition Source:** https://github.com/siderolabs/talos/blob/main/api/security/security.proto 317 | - **Copyright:** Sidero Labs, Inc. 318 | 319 | We gratefully acknowledge the Talos Linux project and Sidero Labs for creating and maintaining the protocol specification that makes this integration possible. 320 | 321 | > A special mention to Andrei Kvapil from [Ænix](https://aenix.io/), the creators of [Cozystack](https://cozystack.io/): 322 | > with its [PoC](https://github.com/cozystack/standalone-trustd/blob/main/POC.md) the ideas has been validated and finalized here. 323 | 324 | ## Documentation 325 | 326 | ### Deployment Guides 327 | 328 | Implementation details and step-by-step instructions: 329 | 330 | - **[Sidecar Deployment (Kamaji)](docs/sidecar-deployment.md)** - Deploy as Kamaji TenantControlPlane sidecar 331 | - **[Standalone Deployment (kubeadm)](docs/standalone-deployment.md)** - Deploy on kubeadm control planes with VIP 332 | 333 | ### External Resources 334 | 335 | - **Talos Linux:** https://www.talos.dev 336 | - **Kamaji:** https://kamaji.clastix.io 337 | - **Kubernetes TLS Bootstrapping:** https://kubernetes.io/docs/reference/access-authn-authz/bootstrap-tokens/ 338 | --------------------------------------------------------------------------------