mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-17 06:23:00 +00:00
320 lines
9.0 KiB
Go
320 lines
9.0 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"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,
|
|
}
|
|
cfg.Defaults()
|
|
|
|
// Step 3: Floating IPs
|
|
fmt.Println()
|
|
fmt.Println("Checking floating IPs...")
|
|
floatingIPs, err := setupFloatingIPs(client, cfg.Location)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.FloatingIPs = floatingIPs
|
|
|
|
// Step 4: Firewall
|
|
fmt.Println()
|
|
fmt.Println("Checking firewall...")
|
|
fwID, err := setupFirewall(client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.FirewallID = fwID
|
|
|
|
// Step 5: SSH key
|
|
fmt.Println()
|
|
fmt.Println("Setting up SSH key...")
|
|
sshKeyConfig, err := setupSSHKey(client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.SSHKey = sshKeyConfig
|
|
|
|
// Step 6: 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 7: 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 likely already exists on Hetzner — find it by listing
|
|
fmt.Printf(" SSH key may already be on Hetzner (upload: %v)\n", err)
|
|
fmt.Print(" Enter the Hetzner SSH key ID (or 0 to re-upload): ")
|
|
reader := bufio.NewReader(os.Stdin)
|
|
idStr, _ := reader.ReadString('\n')
|
|
idStr = strings.TrimSpace(idStr)
|
|
var hetznerID int64
|
|
fmt.Sscanf(idStr, "%d", &hetznerID)
|
|
|
|
if hetznerID == 0 {
|
|
return SSHKeyConfig{}, fmt.Errorf("could not resolve SSH key on Hetzner, try deleting and re-running setup")
|
|
}
|
|
|
|
return SSHKeyConfig{
|
|
HetznerID: hetznerID,
|
|
PrivateKeyPath: "~/.orama/sandbox_key",
|
|
PublicKeyPath: "~/.orama/sandbox_key.pub",
|
|
}, nil
|
|
}
|
|
|
|
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 the sandbox domain resolves.
|
|
func verifyDNS(domain string) {
|
|
fmt.Printf(" Checking NS records for %s...\n", domain)
|
|
out, err := exec.Command("dig", "+short", "NS", domain, "@8.8.8.8").Output()
|
|
if err != nil {
|
|
fmt.Printf(" Warning: dig failed: %v\n", err)
|
|
fmt.Println(" DNS verification skipped. You can verify later with:")
|
|
fmt.Printf(" dig NS %s @8.8.8.8\n", domain)
|
|
return
|
|
}
|
|
|
|
result := strings.TrimSpace(string(out))
|
|
if result == "" {
|
|
fmt.Println(" Warning: No NS records found yet.")
|
|
fmt.Println(" DNS propagation can take up to 48 hours.")
|
|
fmt.Println(" The sandbox will still work once DNS is configured.")
|
|
} else {
|
|
fmt.Printf(" NS records:\n")
|
|
for _, line := range strings.Split(result, "\n") {
|
|
fmt.Printf(" %s\n", line)
|
|
}
|
|
}
|
|
}
|