326 lines
9.7 KiB
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...)
|
|
}
|