Files
panel/agent/cmd/agent/pair.go
T
dbledeez 295eb22826 panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:26:41 -07:00

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
}