clavitor/operations/pop-sync/main_ca.go

326 lines
9.7 KiB
Go

package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"clavitor.ai/pop-sync/ca"
)
// --- Subcommand: ca init ---
// Initialize root CA and intermediate CA for mTLS between POPs.
func cmdCAInit(cfg Config, args []string) []NodeResult {
var outDir string
for i := 0; i < len(args); i++ {
switch args[i] {
case "-out", "-o":
if i+1 < len(args) {
outDir = args[i+1]
i++
}
}
}
if outDir == "" {
outDir = "."
}
// Ensure output directory exists
if err := os.MkdirAll(outDir, 0755); err != nil {
return []NodeResult{{Node: "ca", Action: "init", Error: fmt.Sprintf("create output dir: %v", err)}}
}
// Check if files already exist
files := ca.DefaultCertFiles()
exists := []string{}
for _, path := range []string{
filepath.Join(outDir, files.RootKeyPath),
filepath.Join(outDir, files.RootCertPath),
filepath.Join(outDir, files.IntKeyPath),
filepath.Join(outDir, files.IntCertPath),
} {
if _, err := os.Stat(path); err == nil {
exists = append(exists, path)
}
}
if len(exists) > 0 && !cfg.DryRun {
log(cfg, "WARNING: CA files already exist:")
for _, e := range exists {
log(cfg, " - %s", e)
}
log(cfg, "Use -dry-run to see what would happen, or delete files to regenerate.")
return []NodeResult{{Node: "ca", Action: "init", Error: "CA files already exist, refusing to overwrite"}}
}
if cfg.DryRun {
log(cfg, "Would generate CA in: %s", outDir)
log(cfg, " - %s (root key, keep offline)", files.RootKeyPath)
log(cfg, " - %s (root certificate)", files.RootCertPath)
log(cfg, " - %s (intermediate key)", files.IntKeyPath)
log(cfg, " - %s (intermediate certificate)", files.IntCertPath)
return []NodeResult{{Node: "ca", Action: "init", OK: true, Message: "dry run - would generate CA"}}
}
log(cfg, "Generating CA hierarchy in %s...", outDir)
// Generate CA
caObj, err := ca.Init()
if err != nil {
return []NodeResult{{Node: "ca", Action: "init", Error: fmt.Sprintf("generate CA: %v", err)}}
}
// Save with custom paths
paths := ca.CertFiles{
RootKeyPath: filepath.Join(outDir, files.RootKeyPath),
RootCertPath: filepath.Join(outDir, files.RootCertPath),
IntKeyPath: filepath.Join(outDir, files.IntKeyPath),
IntCertPath: filepath.Join(outDir, files.IntCertPath),
}
if err := caObj.SaveToDisk(paths); err != nil {
return []NodeResult{{Node: "ca", Action: "init", Error: fmt.Sprintf("save CA: %v", err)}}
}
log(cfg, "\nCA initialized successfully:")
log(cfg, " Root CA: %s (valid until %s)", paths.RootCertPath, caObj.RootCert.NotAfter.Format("2006-01-02"))
log(cfg, " Root Key: %s (KEEP SECURE/OFFLINE)", paths.RootKeyPath)
log(cfg, " Intermediate: %s (valid until %s)", paths.IntCertPath, caObj.IntCert.NotAfter.Format("2006-01-02"))
log(cfg, " Int Key: %s", paths.IntKeyPath)
return []NodeResult{{Node: "ca", Action: "init", OK: true, Message: fmt.Sprintf("CA created in %s", outDir)}}
}
// --- Subcommand: ca issue ---
// Issue a certificate for a POP.
func cmdCAIssue(cfg Config, args []string) []NodeResult {
var region string
var sansList string
var outDir string
var caDir string
var validityDays int
for i := 0; i < len(args); i++ {
switch args[i] {
case "--pop", "-pop":
if i+1 < len(args) {
region = args[i+1]
i++
}
case "--sans", "-sans":
if i+1 < len(args) {
sansList = args[i+1]
i++
}
case "-out", "-o":
if i+1 < len(args) {
outDir = args[i+1]
i++
}
case "-ca-dir":
if i+1 < len(args) {
caDir = args[i+1]
i++
}
case "-validity":
if i+1 < len(args) {
fmt.Sscanf(args[i+1], "%d", &validityDays)
i++
}
}
}
if region == "" {
return []NodeResult{{Node: "ca", Action: "issue", Error: "--pop <region> is required"}}
}
if outDir == "" {
outDir = "."
}
if caDir == "" {
caDir = "."
}
if validityDays == 0 {
validityDays = 90
}
// Parse SANs
var sans []string
if sansList != "" {
sans = strings.Split(sansList, ",")
for i := range sans {
sans[i] = strings.TrimSpace(sans[i])
}
}
// Ensure output directory exists
if err := os.MkdirAll(outDir, 0755); err != nil {
return []NodeResult{{Node: region, Action: "issue", Error: fmt.Sprintf("create output dir: %v", err)}}
}
// Load intermediate CA
intKeyPath := filepath.Join(caDir, "intermediate-ca.key")
intCertPath := filepath.Join(caDir, "intermediate-ca.crt")
if cfg.DryRun {
log(cfg, "Would issue certificate for %s:", region)
log(cfg, " SANs: %v", sans)
log(cfg, " Output: %s/%s.{key,crt}", outDir, region)
log(cfg, " Chain: %s/ca-chain.crt", outDir)
return []NodeResult{{Node: region, Action: "issue", OK: true, Message: "dry run - would issue cert"}}
}
// Also need root cert for the chain (already loaded in caObj.RootCert, but we need the raw PEM)
rootCertPath := filepath.Join(caDir, "root-ca.crt")
log(cfg, "Loading intermediate CA from %s...", caDir)
caObj, err := ca.LoadIntermediateOnly(intKeyPath, intCertPath, rootCertPath)
if err != nil {
return []NodeResult{{Node: region, Action: "issue", Error: fmt.Sprintf("load intermediate CA: %v", err)}}
}
log(cfg, "Issuing certificate for %s...", region)
// Issue POP certificate
opts := ca.IssueOptions{
Region: region,
SANs: sans,
Validity: time.Duration(validityDays) * 24 * time.Hour,
}
popCert, err := caObj.Issue(opts)
if err != nil {
return []NodeResult{{Node: region, Action: "issue", Error: fmt.Sprintf("issue certificate: %v", err)}}
}
// Also need root cert for the chain (already loaded in caObj.RootCert, but we need the raw PEM)
rootPEM, err := os.ReadFile(rootCertPath)
if err != nil {
return []NodeResult{{Node: region, Action: "issue", Error: fmt.Sprintf("read root CA: %v", err)}}
}
// Rebuild chain with root cert
popCert.ChainPEM = append(popCert.ChainPEM, '\n')
popCert.ChainPEM = append(popCert.ChainPEM, rootPEM...)
// Save to disk
keyPath := filepath.Join(outDir, fmt.Sprintf("%s.key", region))
certPath := filepath.Join(outDir, fmt.Sprintf("%s.crt", region))
chainPath := filepath.Join(outDir, "ca-chain.crt")
if err := os.WriteFile(keyPath, popCert.KeyPEM, 0600); err != nil {
return []NodeResult{{Node: region, Action: "issue", Error: fmt.Sprintf("save key: %v", err)}}
}
if err := os.WriteFile(certPath, popCert.CertPEM, 0644); err != nil {
return []NodeResult{{Node: region, Action: "issue", Error: fmt.Sprintf("save cert: %v", err)}}
}
if err := os.WriteFile(chainPath, popCert.ChainPEM, 0644); err != nil {
return []NodeResult{{Node: region, Action: "issue", Error: fmt.Sprintf("save chain: %v", err)}}
}
log(cfg, "\nCertificate issued for %s:", region)
log(cfg, " Key: %s", keyPath)
log(cfg, " Cert: %s (valid until %s)", certPath, popCert.Cert.NotAfter.Format("2006-01-02"))
log(cfg, " SANs: %v", popCert.Cert.DNSNames)
if len(popCert.Cert.IPAddresses) > 0 {
log(cfg, " IPs: %v", popCert.Cert.IPAddresses)
}
log(cfg, " Chain: %s", chainPath)
return []NodeResult{{Node: region, Action: "issue", OK: true, Message: fmt.Sprintf("cert issued: %s", certPath)}}
}
// --- Subcommand: sync-pops ---
// Sync pops table from operations DB to corporate DB for customer-facing queries.
func cmdSyncPops(cfg Config) []NodeResult {
// Determine paths
opDbPath := cfg.DBPath
if opDbPath == "" {
opDbPath = "../clavitor.com/clavitor.db"
}
corpDbPath := filepath.Join(filepath.Dir(opDbPath), "admin/corporate.db")
// Check if corporate.db exists
if _, err := os.Stat(corpDbPath); err != nil {
return []NodeResult{{Node: "sync-pops", Action: "sync", Error: fmt.Sprintf("corporate.db not found at %s", corpDbPath)}}
}
log(cfg, "Syncing pops from %s to %s...", opDbPath, corpDbPath)
if cfg.DryRun {
log(cfg, "Dry run: would sync %d POPs from ops DB to corporate DB", len(loadLivePOPs(cfg)))
return []NodeResult{{Node: "sync-pops", Action: "sync", OK: true, Message: "dry run"}}
}
// Execute sync via sqlite3
// We'll use a temporary SQL file
tmpFile := "/tmp/sync_pops_" + fmt.Sprintf("%d", time.Now().Unix()) + ".sql"
sqlContent := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS pops (
pop_id INTEGER PRIMARY KEY,
city TEXT NOT NULL,
country TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
region_name TEXT NOT NULL,
ip TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'planned',
price_month REAL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
provider TEXT DEFAULT '',
dns TEXT DEFAULT '',
instance_id TEXT DEFAULT '',
backup_city TEXT DEFAULT '',
backup_distance_km INTEGER DEFAULT 0
);
ATTACH '%s' AS op;
DELETE FROM main.pops;
INSERT INTO main.pops SELECT * FROM op.pops;
DETACH op;`, opDbPath)
if err := os.WriteFile(tmpFile, []byte(sqlContent), 0644); err != nil {
return []NodeResult{{Node: "sync-pops", Action: "sync", Error: fmt.Sprintf("write temp SQL: %v", err)}}
}
defer os.Remove(tmpFile)
// Run sqlite3
cmd := caCmd("sqlite3", corpDbPath, ".read "+tmpFile)
out, err := cmd.CombinedOutput()
if err != nil {
return []NodeResult{{Node: "sync-pops", Action: "sync", Error: fmt.Sprintf("sync failed: %v\n%s", err, string(out))}}
}
// Verify
verifyCmd := caCmd("sqlite3", corpDbPath, "SELECT COUNT(*) FROM pops;")
verifyOut, _ := verifyCmd.CombinedOutput()
count := strings.TrimSpace(string(verifyOut))
log(cfg, "✓ Synced %s POPs to corporate.db", count)
return []NodeResult{{Node: "sync-pops", Action: "sync", OK: true, Message: fmt.Sprintf("synced %s POPs", count)}}
}
// caCmd creates an exec.Command for CA operations
func caCmd(name string, args ...string) caExecCmd {
return caExecCmd{Cmd: execCommand(name, args...)}
}
type caExecCmd struct {
Cmd *exec.Cmd
}
func (c caExecCmd) CombinedOutput() ([]byte, error) {
return c.Cmd.CombinedOutput()
}
// execCommand is a variable for testing
var execCommand = execCommandReal
func execCommandReal(name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
}