From b62515272f587274ec772dfd3a9c8fd0eb676d94 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 20 Feb 2026 09:55:36 -0500 Subject: [PATCH] caddybunnn --- caddy.go | 80 +++++++++++++++++++++++++++++++++++++ config.go | 106 +++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 ++ main.go | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++ porkbun.go | 64 ++++++++++++++++++++++++++++++ 5 files changed, 366 insertions(+) create mode 100644 caddy.go create mode 100644 config.go create mode 100644 go.mod create mode 100644 main.go create mode 100644 porkbun.go diff --git a/caddy.go b/caddy.go new file mode 100644 index 0000000..e74f8d0 --- /dev/null +++ b/caddy.go @@ -0,0 +1,80 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" +) + +func AddCaddyRoute(cfg *Config, fqdn string, port string) error { + if err := addCaddyAPIRoute(cfg, fqdn, port); err != nil { + return fmt.Errorf("caddy API: %w", err) + } + + if err := appendCaddyfile(cfg, fqdn, port); err != nil { + return fmt.Errorf("caddyfile: %w", err) + } + + return nil +} + +func addCaddyAPIRoute(cfg *Config, fqdn string, port string) error { + route := map[string]interface{}{ + "match": []map[string]interface{}{ + {"host": []string{fqdn}}, + }, + "handle": []map[string]interface{}{ + { + "handler": "reverse_proxy", + "upstreams": []map[string]string{ + {"dial": "localhost:" + port}, + }, + }, + }, + } + + payload, err := json.Marshal(route) + if err != nil { + return fmt.Errorf("marshaling route: %w", err) + } + + url := cfg.CaddyAdminURL + "/config/apps/http/servers/srv0/routes" + + req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +func appendCaddyfile(cfg *Config, fqdn string, port string) error { + block := fmt.Sprintf("\n%s {\n\treverse_proxy localhost:%s\n}\n", fqdn, port) + + f, err := os.OpenFile(cfg.CaddyfilePath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("opening Caddyfile: %w", err) + } + defer f.Close() + + if _, err := f.WriteString(block); err != nil { + return fmt.Errorf("writing to Caddyfile: %w", err) + } + + return nil +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..53d38fc --- /dev/null +++ b/config.go @@ -0,0 +1,106 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type Config struct { + PorkbunAPIKey string `json:"porkbun_api_key"` + PorkbunSecretKey string `json:"porkbun_secret_key"` + Domain string `json:"domain"` + CaddyAdminURL string `json:"caddy_admin_url"` + CaddyfilePath string `json:"caddyfile_path"` +} + +func configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting home directory: %w", err) + } + return filepath.Join(home, ".config", "caddybun", "config.json"), nil +} + +func LoadOrCreateConfig() (*Config, error) { + path, err := configPath() + if err != nil { + return nil, err + } + + cfg := &Config{ + Domain: "torrtle.co", + CaddyAdminURL: "http://localhost:2019", + CaddyfilePath: "/etc/caddy/Caddyfile", + } + + data, err := os.ReadFile(path) + if err == nil { + if err := json.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + return cfg, nil + } + + if !os.IsNotExist(err) { + return nil, fmt.Errorf("reading config: %w", err) + } + + // First run — prompt for API keys + fmt.Println("First run — let's configure caddybun.") + fmt.Println() + + reader := bufio.NewReader(os.Stdin) + + fmt.Print("Porkbun API key: ") + cfg.PorkbunAPIKey, _ = reader.ReadString('\n') + cfg.PorkbunAPIKey = strings.TrimSpace(cfg.PorkbunAPIKey) + + fmt.Print("Porkbun Secret API key: ") + cfg.PorkbunSecretKey, _ = reader.ReadString('\n') + cfg.PorkbunSecretKey = strings.TrimSpace(cfg.PorkbunSecretKey) + + fmt.Printf("Domain [%s]: ", cfg.Domain) + if d, _ := reader.ReadString('\n'); strings.TrimSpace(d) != "" { + cfg.Domain = strings.TrimSpace(d) + } + + fmt.Printf("Caddy admin URL [%s]: ", cfg.CaddyAdminURL) + if u, _ := reader.ReadString('\n'); strings.TrimSpace(u) != "" { + cfg.CaddyAdminURL = strings.TrimSpace(u) + } + + fmt.Printf("Caddyfile path [%s]: ", cfg.CaddyfilePath) + if p, _ := reader.ReadString('\n'); strings.TrimSpace(p) != "" { + cfg.CaddyfilePath = strings.TrimSpace(p) + } + + fmt.Println() + + if err := saveConfig(path, cfg); err != nil { + return nil, err + } + + fmt.Printf("Config saved to %s\n\n", path) + return cfg, nil +} + +func saveConfig(path string, cfg *Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("writing config: %w", err) + } + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b81e166 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module caddybun + +go 1.19 diff --git a/main.go b/main.go new file mode 100644 index 0000000..33babba --- /dev/null +++ b/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "os" + "strconv" + "strings" +) + +func main() { + subdomain := flag.String("subdomain", "", "subdomain name (without .domain)") + port := flag.String("port", "", "target port") + yes := flag.Bool("y", false, "skip confirmation") + flag.Parse() + + cfg, err := LoadOrCreateConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + reader := bufio.NewReader(os.Stdin) + + if *subdomain == "" { + fmt.Printf("Subdomain (without .%s): ", cfg.Domain) + *subdomain, _ = reader.ReadString('\n') + *subdomain = strings.TrimSpace(*subdomain) + } + + if err := validateSubdomain(*subdomain); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if *port == "" { + fmt.Print("Target port: ") + *port, _ = reader.ReadString('\n') + *port = strings.TrimSpace(*port) + } + + if err := validatePort(*port); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fqdn := *subdomain + "." + cfg.Domain + + fmt.Println() + fmt.Printf(" DNS: CNAME %s → %s (Porkbun)\n", fqdn, cfg.Domain) + fmt.Printf(" Caddy: %s → localhost:%s\n", fqdn, *port) + fmt.Println() + + if !*yes { + fmt.Print("Proceed? [Y/n] ") + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "" && answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + os.Exit(0) + } + } + + fmt.Println() + + // Step 1: DNS + fmt.Print("[1/2] Creating DNS record on Porkbun... ") + _, err = CreateDNSRecord(cfg, *subdomain) + if err != nil { + fmt.Println("✗") + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Println("✓") + + // Step 2: Caddy + fmt.Print("[2/2] Adding Caddy route... ") + err = AddCaddyRoute(cfg, fqdn, *port) + if err != nil { + fmt.Println("✗") + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintln(os.Stderr, "Warning: DNS record was created successfully. The Caddy route must be added manually.") + os.Exit(1) + } + fmt.Println("✓") + + fmt.Printf("\nDone! %s → localhost:%s\n", fqdn, *port) +} + +func validateSubdomain(s string) error { + if s == "" { + return fmt.Errorf("subdomain cannot be empty") + } + if strings.ContainsAny(s, ". \t") { + return fmt.Errorf("subdomain must not contain dots or spaces") + } + return nil +} + +func validatePort(p string) error { + if p == "" { + return fmt.Errorf("port cannot be empty") + } + n, err := strconv.Atoi(p) + if err != nil { + return fmt.Errorf("port must be a number") + } + if n < 1 || n > 65535 { + return fmt.Errorf("port must be between 1 and 65535") + } + return nil +} diff --git a/porkbun.go b/porkbun.go new file mode 100644 index 0000000..b55d212 --- /dev/null +++ b/porkbun.go @@ -0,0 +1,64 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type porkbunRequest struct { + APIKey string `json:"apikey"` + SecretAPIKey string `json:"secretapikey"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL string `json:"ttl"` +} + +type porkbunResponse struct { + Status string `json:"status"` + ID json.Number `json:"id"` + Message string `json:"message"` +} + +func CreateDNSRecord(cfg *Config, subdomain string) (string, error) { + url := fmt.Sprintf("https://api.porkbun.com/api/json/v3/dns/create/%s", cfg.Domain) + + body := porkbunRequest{ + APIKey: cfg.PorkbunAPIKey, + SecretAPIKey: cfg.PorkbunSecretKey, + Name: subdomain, + Type: "CNAME", + Content: cfg.Domain, + TTL: "600", + } + + payload, err := json.Marshal(body) + if err != nil { + return "", fmt.Errorf("marshaling request: %w", err) + } + + resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) + if err != nil { + return "", fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + var result porkbunResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("parsing response: %w", err) + } + + if result.Status != "SUCCESS" { + return "", fmt.Errorf("porkbun API error: %s", result.Message) + } + + return result.ID.String(), nil +}