0f6aea796c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
173 lines
5.3 KiB
Go
173 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Pairing flow (agent side):
|
|
//
|
|
// 1. Fetch the controller's CA cert over HTTPS (with InsecureSkipVerify —
|
|
// this is the only moment we're exposed; the one-time token gates
|
|
// the actual cert issuance below).
|
|
// 2. Generate an RSA-2048 keypair locally. The private key never
|
|
// leaves this host.
|
|
// 3. Build a CSR with CN = agent-id.
|
|
// 4. POST {token, agent_id, csr_pem} to /api/pair/issue, now verifying
|
|
// the server cert against the CA we just fetched.
|
|
// 5. Persist ca.crt + agent.crt + agent.key to --cert-dir, mode 0600 on the key.
|
|
//
|
|
// After pairing, run the agent normally; it auto-detects the cert files
|
|
// and connects to the controller with mTLS.
|
|
|
|
type pairConfig struct {
|
|
AgentID string
|
|
PairURL string // e.g. https://controller:8080 — the HTTP dashboard port
|
|
Token string
|
|
CertDir string
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
func runPair(cfg pairConfig) error {
|
|
cfg.Logger.Info("pair: starting", "pair_url", cfg.PairURL, "agent_id", cfg.AgentID, "cert_dir", cfg.CertDir)
|
|
|
|
if err := os.MkdirAll(cfg.CertDir, 0o755); err != nil {
|
|
return fmt.Errorf("mkdir cert-dir: %w", err)
|
|
}
|
|
|
|
// Step 1 — fetch CA with InsecureSkipVerify, then verify with it.
|
|
caPEM, err := fetchCA(cfg.PairURL, cfg.Logger)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch CA: %w", err)
|
|
}
|
|
caPath := filepath.Join(cfg.CertDir, "ca.crt")
|
|
if err := os.WriteFile(caPath, caPEM, 0o644); err != nil {
|
|
return fmt.Errorf("write ca.crt: %w", err)
|
|
}
|
|
cfg.Logger.Info("pair: CA saved", "path", caPath, "bytes", len(caPEM))
|
|
|
|
// Step 2 — keypair + CSR
|
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return fmt.Errorf("gen key: %w", err)
|
|
}
|
|
tpl := &x509.CertificateRequest{
|
|
Subject: pkix.Name{CommonName: cfg.AgentID, Organization: []string{"panel-agent"}},
|
|
DNSNames: []string{cfg.AgentID},
|
|
}
|
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, tpl, priv)
|
|
if err != nil {
|
|
return fmt.Errorf("create CSR: %w", err)
|
|
}
|
|
csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
|
|
|
|
// Step 3 — POST to /api/pair/issue, verifying CA.
|
|
certPEM, err := issueCert(cfg.PairURL, caPEM, cfg.Token, cfg.AgentID, csrPEM, cfg.Logger)
|
|
if err != nil {
|
|
return fmt.Errorf("issue cert: %w", err)
|
|
}
|
|
|
|
// Step 4 — persist.
|
|
if err := os.WriteFile(filepath.Join(cfg.CertDir, "agent.crt"), certPEM, 0o644); err != nil {
|
|
return err
|
|
}
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
|
if err := os.WriteFile(filepath.Join(cfg.CertDir, "agent.key"), keyPEM, 0o600); err != nil {
|
|
return err
|
|
}
|
|
cfg.Logger.Info("pair: cert issued", "cert_dir", cfg.CertDir, "agent_id", cfg.AgentID)
|
|
return nil
|
|
}
|
|
|
|
func fetchCA(baseURL string, log *slog.Logger) ([]byte, error) {
|
|
trimmed := strings.TrimSuffix(baseURL, "/")
|
|
url := trimmed + "/api/pair/ca"
|
|
log.Info("pair: fetching CA", "url", url)
|
|
|
|
// Until we have the CA we can't verify the server. InsecureSkipVerify
|
|
// is narrowly scoped to this one request — the cert-issuance POST
|
|
// below verifies against the CA we just got.
|
|
cli := &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
|
|
}
|
|
resp, err := cli.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
block, _ := pem.Decode(data)
|
|
if block == nil || block.Type != "CERTIFICATE" {
|
|
return nil, fmt.Errorf("response isn't a CERTIFICATE PEM")
|
|
}
|
|
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
|
return nil, fmt.Errorf("parse CA cert: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func issueCert(baseURL string, caPEM []byte, token, agentID string, csrPEM []byte, log *slog.Logger) ([]byte, error) {
|
|
trimmed := strings.TrimSuffix(baseURL, "/")
|
|
url := trimmed + "/api/pair/issue"
|
|
log.Info("pair: requesting cert", "url", url, "agent_id", agentID)
|
|
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caPEM) {
|
|
return nil, fmt.Errorf("append CA to pool failed")
|
|
}
|
|
cli := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}},
|
|
}
|
|
body, _ := json.Marshal(map[string]string{
|
|
"token": token,
|
|
"agent_id": agentID,
|
|
"csr_pem": string(csrPEM),
|
|
})
|
|
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := cli.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(errBody))
|
|
}
|
|
var out struct {
|
|
CertPEM string `json:"cert_pem"`
|
|
CAPEM string `json:"ca_pem"`
|
|
Subject string `json:"subject"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return nil, err
|
|
}
|
|
if out.CertPEM == "" {
|
|
return nil, fmt.Errorf("response missing cert_pem")
|
|
}
|
|
return []byte(out.CertPEM), nil
|
|
}
|