orama/pkg/cli/sandbox/setup.go
anonpenguin23 a0468461ab feat(sandbox): add reset command and interactive setup
- new `orama sandbox reset` deletes Hetzner resources (IPs, firewall, SSH key) and local config
- interactive location/server type selection during `setup`
- add Hetzner API methods for listing locations/types, deleting resources
- update defaults to nbg1/cx23
2026-02-28 10:14:02 +02:00

604 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package sandbox
import (
"bufio"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"fmt"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
// Setup runs the interactive sandbox setup wizard.
func Setup() error {
fmt.Println("Orama Sandbox Setup")
fmt.Println("====================")
fmt.Println()
reader := bufio.NewReader(os.Stdin)
// Step 1: Hetzner API token
fmt.Print("Hetzner Cloud API token: ")
token, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("read token: %w", err)
}
token = strings.TrimSpace(token)
if token == "" {
return fmt.Errorf("API token is required")
}
fmt.Print(" Validating token... ")
client := NewHetznerClient(token)
if err := client.ValidateToken(); err != nil {
fmt.Println("FAILED")
return fmt.Errorf("invalid token: %w", err)
}
fmt.Println("OK")
fmt.Println()
// Step 2: Domain
fmt.Print("Sandbox domain (e.g., sbx.dbrs.space): ")
domain, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("read domain: %w", err)
}
domain = strings.TrimSpace(domain)
if domain == "" {
return fmt.Errorf("domain is required")
}
cfg := &Config{
HetznerAPIToken: token,
Domain: domain,
}
// Step 3: Location selection
fmt.Println()
location, err := selectLocation(client, reader)
if err != nil {
return err
}
cfg.Location = location
// Step 4: Server type selection
fmt.Println()
serverType, err := selectServerType(client, reader, location)
if err != nil {
return err
}
cfg.ServerType = serverType
// Step 5: Floating IPs
fmt.Println()
fmt.Println("Checking floating IPs...")
floatingIPs, err := setupFloatingIPs(client, cfg.Location)
if err != nil {
return err
}
cfg.FloatingIPs = floatingIPs
// Step 6: Firewall
fmt.Println()
fmt.Println("Checking firewall...")
fwID, err := setupFirewall(client)
if err != nil {
return err
}
cfg.FirewallID = fwID
// Step 7: SSH key
fmt.Println()
fmt.Println("Setting up SSH key...")
sshKeyConfig, err := setupSSHKey(client)
if err != nil {
return err
}
cfg.SSHKey = sshKeyConfig
// Step 8: Display DNS instructions
fmt.Println()
fmt.Println("DNS Configuration")
fmt.Println("-----------------")
fmt.Println("Configure the following at your domain registrar:")
fmt.Println()
fmt.Printf(" 1. Add glue records (Personal DNS Servers):\n")
fmt.Printf(" ns1.%s -> %s\n", domain, cfg.FloatingIPs[0].IP)
fmt.Printf(" ns2.%s -> %s\n", domain, cfg.FloatingIPs[1].IP)
fmt.Println()
fmt.Printf(" 2. Set custom nameservers for %s:\n", domain)
fmt.Printf(" ns1.%s\n", domain)
fmt.Printf(" ns2.%s\n", domain)
fmt.Println()
// Step 9: Verify DNS (optional)
fmt.Print("Verify DNS now? [y/N]: ")
verifyChoice, _ := reader.ReadString('\n')
verifyChoice = strings.TrimSpace(strings.ToLower(verifyChoice))
if verifyChoice == "y" || verifyChoice == "yes" {
verifyDNS(domain, cfg.FloatingIPs, reader)
}
// Save config
if err := SaveConfig(cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
fmt.Println()
fmt.Println("Setup complete! Config saved to ~/.orama/sandbox.yaml")
fmt.Println()
fmt.Println("Next: orama sandbox create")
return nil
}
// selectLocation fetches available Hetzner locations and lets the user pick one.
func selectLocation(client *HetznerClient, reader *bufio.Reader) (string, error) {
fmt.Println("Fetching available locations...")
locations, err := client.ListLocations()
if err != nil {
return "", fmt.Errorf("list locations: %w", err)
}
sort.Slice(locations, func(i, j int) bool {
return locations[i].Name < locations[j].Name
})
defaultLoc := "nbg1"
fmt.Println(" Available datacenter locations:")
for i, loc := range locations {
def := ""
if loc.Name == defaultLoc {
def = " (default)"
}
fmt.Printf(" %d) %s — %s, %s%s\n", i+1, loc.Name, loc.City, loc.Country, def)
}
fmt.Printf("\n Select location [%s]: ", defaultLoc)
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
if choice == "" {
fmt.Printf(" Using %s\n", defaultLoc)
return defaultLoc, nil
}
// Try as number first
if num, err := strconv.Atoi(choice); err == nil && num >= 1 && num <= len(locations) {
loc := locations[num-1].Name
fmt.Printf(" Using %s\n", loc)
return loc, nil
}
// Try as location name
for _, loc := range locations {
if strings.EqualFold(loc.Name, choice) {
fmt.Printf(" Using %s\n", loc.Name)
return loc.Name, nil
}
}
return "", fmt.Errorf("unknown location %q", choice)
}
// selectServerType fetches available server types for a location and lets the user pick one.
func selectServerType(client *HetznerClient, reader *bufio.Reader, location string) (string, error) {
fmt.Println("Fetching available server types...")
serverTypes, err := client.ListServerTypes()
if err != nil {
return "", fmt.Errorf("list server types: %w", err)
}
// Filter to x86 shared-vCPU types available at the selected location, skip deprecated
type option struct {
name string
cores int
memory float64
disk int
hourly string
monthly string
}
var options []option
for _, st := range serverTypes {
if st.Architecture != "x86" {
continue
}
if st.Deprecation != nil {
continue
}
// Only show shared-vCPU types (cx/cpx prefixes) — skip dedicated (ccx/cx5x)
if !strings.HasPrefix(st.Name, "cx") && !strings.HasPrefix(st.Name, "cpx") {
continue
}
// Find pricing for the selected location
hourly, monthly := "", ""
for _, p := range st.Prices {
if p.Location == location {
hourly = p.Hourly.Gross
monthly = p.Monthly.Gross
break
}
}
if hourly == "" {
continue // Not available in this location
}
options = append(options, option{
name: st.Name,
cores: st.Cores,
memory: st.Memory,
disk: st.Disk,
hourly: hourly,
monthly: monthly,
})
}
if len(options) == 0 {
return "", fmt.Errorf("no server types available in %s", location)
}
// Sort by hourly price (cheapest first)
sort.Slice(options, func(i, j int) bool {
pi, _ := strconv.ParseFloat(options[i].hourly, 64)
pj, _ := strconv.ParseFloat(options[j].hourly, 64)
return pi < pj
})
defaultType := options[0].name // cheapest
fmt.Printf(" Available server types in %s:\n", location)
for i, opt := range options {
def := ""
if opt.name == defaultType {
def = " (default)"
}
fmt.Printf(" %d) %-8s %d vCPU / %4.0f GB RAM / %3d GB disk — €%s/hr (€%s/mo)%s\n",
i+1, opt.name, opt.cores, opt.memory, opt.disk, formatPrice(opt.hourly), formatPrice(opt.monthly), def)
}
fmt.Printf("\n Select server type [%s]: ", defaultType)
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
if choice == "" {
fmt.Printf(" Using %s (×5 nodes ≈ €%s/hr)\n", defaultType, multiplyPrice(options[0].hourly, 5))
return defaultType, nil
}
// Try as number
if num, err := strconv.Atoi(choice); err == nil && num >= 1 && num <= len(options) {
opt := options[num-1]
fmt.Printf(" Using %s (×5 nodes ≈ €%s/hr)\n", opt.name, multiplyPrice(opt.hourly, 5))
return opt.name, nil
}
// Try as name
for _, opt := range options {
if strings.EqualFold(opt.name, choice) {
fmt.Printf(" Using %s (×5 nodes ≈ €%s/hr)\n", opt.name, multiplyPrice(opt.hourly, 5))
return opt.name, nil
}
}
return "", fmt.Errorf("unknown server type %q", choice)
}
// formatPrice trims trailing zeros from a price string like "0.0063000000000000" → "0.0063".
func formatPrice(price string) string {
f, err := strconv.ParseFloat(price, 64)
if err != nil {
return price
}
// Use enough precision then trim trailing zeros
s := fmt.Sprintf("%.4f", f)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
return s
}
// multiplyPrice multiplies a price string by n and returns formatted.
func multiplyPrice(price string, n int) string {
f, err := strconv.ParseFloat(price, 64)
if err != nil {
return "?"
}
return formatPrice(fmt.Sprintf("%.10f", f*float64(n)))
}
// setupFloatingIPs checks for existing floating IPs or creates new ones.
func setupFloatingIPs(client *HetznerClient, location string) ([]FloatIP, error) {
existing, err := client.ListFloatingIPsByLabel("orama-sandbox-dns=true")
if err != nil {
return nil, fmt.Errorf("list floating IPs: %w", err)
}
if len(existing) >= 2 {
fmt.Printf(" Found %d existing floating IPs:\n", len(existing))
result := make([]FloatIP, 2)
for i := 0; i < 2; i++ {
fmt.Printf(" ns%d: %s (ID: %d)\n", i+1, existing[i].IP, existing[i].ID)
result[i] = FloatIP{ID: existing[i].ID, IP: existing[i].IP}
}
return result, nil
}
// Need to create missing floating IPs
needed := 2 - len(existing)
fmt.Printf(" Need to create %d floating IP(s)...\n", needed)
reader := bufio.NewReader(os.Stdin)
fmt.Printf(" Create %d floating IP(s) in %s? (~$0.005/hr each) [Y/n]: ", needed, location)
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(strings.ToLower(choice))
if choice == "n" || choice == "no" {
return nil, fmt.Errorf("floating IPs required, aborting setup")
}
result := make([]FloatIP, 0, 2)
for _, fip := range existing {
result = append(result, FloatIP{ID: fip.ID, IP: fip.IP})
}
for i := len(existing); i < 2; i++ {
desc := fmt.Sprintf("orama-sandbox-ns%d", i+1)
labels := map[string]string{"orama-sandbox-dns": "true"}
fip, err := client.CreateFloatingIP(location, desc, labels)
if err != nil {
return nil, fmt.Errorf("create floating IP %d: %w", i+1, err)
}
fmt.Printf(" Created ns%d: %s (ID: %d)\n", i+1, fip.IP, fip.ID)
result = append(result, FloatIP{ID: fip.ID, IP: fip.IP})
}
return result, nil
}
// setupFirewall ensures a sandbox firewall exists.
func setupFirewall(client *HetznerClient) (int64, error) {
existing, err := client.ListFirewallsByLabel("orama-sandbox=infra")
if err != nil {
return 0, fmt.Errorf("list firewalls: %w", err)
}
if len(existing) > 0 {
fmt.Printf(" Found existing firewall: %s (ID: %d)\n", existing[0].Name, existing[0].ID)
return existing[0].ID, nil
}
fmt.Print(" Creating sandbox firewall... ")
fw, err := client.CreateFirewall(
"orama-sandbox",
SandboxFirewallRules(),
map[string]string{"orama-sandbox": "infra"},
)
if err != nil {
fmt.Println("FAILED")
return 0, fmt.Errorf("create firewall: %w", err)
}
fmt.Printf("OK (ID: %d)\n", fw.ID)
return fw.ID, nil
}
// setupSSHKey generates an SSH keypair and uploads it to Hetzner.
func setupSSHKey(client *HetznerClient) (SSHKeyConfig, error) {
dir, err := configDir()
if err != nil {
return SSHKeyConfig{}, err
}
privPath := dir + "/sandbox_key"
pubPath := privPath + ".pub"
// Check for existing key
if _, err := os.Stat(privPath); err == nil {
fmt.Printf(" SSH key already exists: %s\n", privPath)
// Read public key and check if it's on Hetzner
pubData, err := os.ReadFile(pubPath)
if err != nil {
return SSHKeyConfig{}, fmt.Errorf("read public key: %w", err)
}
// Try to upload (will fail with uniqueness error if already exists)
key, err := client.UploadSSHKey("orama-sandbox", strings.TrimSpace(string(pubData)))
if err != nil {
// Key already exists on Hetzner — find it by fingerprint
sshPubKey, _, _, _, parseErr := ssh.ParseAuthorizedKey(pubData)
if parseErr != nil {
return SSHKeyConfig{}, fmt.Errorf("parse public key to find fingerprint: %w", parseErr)
}
fingerprint := ssh.FingerprintLegacyMD5(sshPubKey)
existing, listErr := client.ListSSHKeysByFingerprint(fingerprint)
if listErr == nil && len(existing) > 0 {
fmt.Printf(" Found existing SSH key on Hetzner (ID: %d)\n", existing[0].ID)
return SSHKeyConfig{
HetznerID: existing[0].ID,
PrivateKeyPath: "~/.orama/sandbox_key",
PublicKeyPath: "~/.orama/sandbox_key.pub",
}, nil
}
return SSHKeyConfig{}, fmt.Errorf("SSH key exists locally but could not find it on Hetzner (fingerprint: %s): %w", fingerprint, err)
}
fmt.Printf(" Uploaded to Hetzner (ID: %d)\n", key.ID)
return SSHKeyConfig{
HetznerID: key.ID,
PrivateKeyPath: "~/.orama/sandbox_key",
PublicKeyPath: "~/.orama/sandbox_key.pub",
}, nil
}
// Generate new ed25519 keypair
fmt.Print(" Generating ed25519 keypair... ")
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
fmt.Println("FAILED")
return SSHKeyConfig{}, fmt.Errorf("generate key: %w", err)
}
// Marshal private key to OpenSSH format
pemBlock, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
fmt.Println("FAILED")
return SSHKeyConfig{}, fmt.Errorf("marshal private key: %w", err)
}
privPEM := pem.EncodeToMemory(pemBlock)
if err := os.WriteFile(privPath, privPEM, 0600); err != nil {
fmt.Println("FAILED")
return SSHKeyConfig{}, fmt.Errorf("write private key: %w", err)
}
// Marshal public key to authorized_keys format
sshPubKey, err := ssh.NewPublicKey(pubKey)
if err != nil {
return SSHKeyConfig{}, fmt.Errorf("convert public key: %w", err)
}
pubStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPubKey)))
if err := os.WriteFile(pubPath, []byte(pubStr+"\n"), 0644); err != nil {
return SSHKeyConfig{}, fmt.Errorf("write public key: %w", err)
}
fmt.Println("OK")
// Upload to Hetzner
fmt.Print(" Uploading to Hetzner... ")
key, err := client.UploadSSHKey("orama-sandbox", pubStr)
if err != nil {
fmt.Println("FAILED")
return SSHKeyConfig{}, fmt.Errorf("upload SSH key: %w", err)
}
fmt.Printf("OK (ID: %d)\n", key.ID)
return SSHKeyConfig{
HetznerID: key.ID,
PrivateKeyPath: "~/.orama/sandbox_key",
PublicKeyPath: "~/.orama/sandbox_key.pub",
}, nil
}
// verifyDNS checks if glue records for the sandbox domain are configured.
//
// There's a chicken-and-egg problem: NS records can't fully resolve until
// CoreDNS is running on the floating IPs (which requires a sandbox cluster).
// So instead of resolving NS → A records, we check for glue records at the
// TLD level, which proves the registrar configuration is correct.
func verifyDNS(domain string, floatingIPs []FloatIP, reader *bufio.Reader) {
expectedIPs := make(map[string]bool)
for _, fip := range floatingIPs {
expectedIPs[fip.IP] = true
}
// Find the TLD nameserver to query for glue records
findTLDServer := func() string {
// For "dbrs.space", the TLD is "space." — ask the root for its NS
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return ""
}
tld := parts[len(parts)-1]
out, err := exec.Command("dig", "+short", "NS", tld+".", "@8.8.8.8").Output()
if err != nil {
return ""
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) > 0 && lines[0] != "" {
return strings.TrimSpace(lines[0])
}
return ""
}
check := func() (glueFound bool, foundIPs []string) {
tldNS := findTLDServer()
if tldNS == "" {
return false, nil
}
// Query the TLD nameserver for NS + glue of our domain
// dig NS domain @tld-server will include glue in ADDITIONAL section
out, err := exec.Command("dig", "NS", domain, "@"+tldNS, "+norecurse", "+additional").Output()
if err != nil {
return false, nil
}
output := string(out)
remaining := make(map[string]bool)
for k, v := range expectedIPs {
remaining[k] = v
}
// Look for our floating IPs in the ADDITIONAL section (glue records)
// or anywhere in the response
for _, fip := range floatingIPs {
if strings.Contains(output, fip.IP) {
foundIPs = append(foundIPs, fip.IP)
delete(remaining, fip.IP)
}
}
return len(remaining) == 0, foundIPs
}
fmt.Printf(" Checking glue records for %s at TLD nameserver...\n", domain)
matched, foundIPs := check()
if matched {
fmt.Println(" ✓ Glue records configured correctly:")
for i, ip := range foundIPs {
fmt.Printf(" ns%d.%s → %s\n", i+1, domain, ip)
}
fmt.Println()
fmt.Println(" Note: Full DNS resolution will work once a sandbox is running")
fmt.Println(" (CoreDNS on the floating IPs needs to be up to answer queries).")
return
}
if len(foundIPs) > 0 {
fmt.Println(" ⚠ Partial glue records found:")
for _, ip := range foundIPs {
fmt.Printf(" %s\n", ip)
}
fmt.Println(" Missing floating IPs in glue:")
for _, fip := range floatingIPs {
if expectedIPs[fip.IP] {
fmt.Printf(" %s\n", fip.IP)
}
}
} else {
fmt.Println(" ✗ No glue records found yet.")
fmt.Println(" Make sure you configured at your registrar:")
fmt.Printf(" ns1.%s → %s\n", domain, floatingIPs[0].IP)
fmt.Printf(" ns2.%s → %s\n", domain, floatingIPs[1].IP)
}
fmt.Println()
fmt.Print(" Wait for glue propagation? (polls every 30s, Ctrl+C to stop) [y/N]: ")
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(strings.ToLower(choice))
if choice != "y" && choice != "yes" {
fmt.Println(" Skipping. You can create the sandbox now — DNS will work once glue propagates.")
return
}
fmt.Println(" Waiting for glue record propagation...")
for i := 1; ; i++ {
time.Sleep(30 * time.Second)
matched, _ = check()
if matched {
fmt.Printf("\n ✓ Glue records propagated after %d checks\n", i)
fmt.Println(" You can now create a sandbox: orama sandbox create")
return
}
fmt.Printf(" [%d] Not yet... checking again in 30s\n", i)
}
}