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 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...) }