package webui import ( "fmt" "net/http" "os" "path/filepath" "strings" "time" "mailgoserver/internal/acmecert" ) // leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] (DNS-01) settings — // always persisted from the submitted form, same as any other settings.html field. var leAlwaysOverwriteFields = []string{ "enabled", "staging", "contact_email", "domains", "dns_provider", "route53_region", "route53_hosted_zone_id", "gcloud_project", } // leHTTPFields are the [LetsEncryptHTTP] (HTTP-01) settings, all plain — no secrets to // redact, unlike the DNS-01 providers' API credentials. var leHTTPFields = []string{ "enabled", "staging", "contact_email", "domains", "include_ip", "ip_override", } // leSecretFields hold DNS provider credentials. They're never rendered back into the // form (always blank) and the save handler only overwrites the stored value when the // submitted field is non-empty — "leave blank to keep the current value", the same // idiom edit_sender.html already uses for its password field. var leSecretFields = []string{ "cloudflare_api_token", "route53_access_key_id", "route53_secret_access_key", "digitalocean_api_token", "gcloud_service_account_json_path", } // letsEncryptPage shows the current Let's Encrypt status and configuration forms for // both the DNS-01 and HTTP-01 managers. Secret fields are always blank in the rendered // form — see leSecretFields. func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) { sec := a.Cfg.Section("LetsEncrypt") kv := M{} for _, k := range leAlwaysOverwriteFields { kv[k] = sec.Key(k).String() } for _, k := range leSecretFields { kv[k] = "" } httpSec := a.Cfg.Section("LetsEncryptHTTP") httpKV := M{} for _, k := range leHTTPFields { httpKV[k] = httpSec.Key(k).String() } a.render(w, r, "letsencrypt.html", M{ "active": "letsencrypt", "le": kv, "status": a.ACME.Status(), "leHTTP": httpKV, "statusHTTP": a.ACMEHTTP.Status(), }) } // letsEncryptSave is a dedicated handler (not the generic settingsUpdate reflection) // specifically because of leSecretFields' blank-means-keep-existing semantics — // settingsUpdate would otherwise blank out a stored credential whenever this form is // submitted with a secret field left empty. func (a *App) letsEncryptSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { setFlash(w, "error", "Invalid form data") http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) return } sec := a.Cfg.Section("LetsEncrypt") for _, k := range leAlwaysOverwriteFields { sec.Key(k).SetValue(r.FormValue(k)) } for _, k := range leSecretFields { if v := r.FormValue(k); v != "" { sec.Key(k).SetValue(v) } } if err := a.Cfg.SaveTo(a.ConfigPath); err != nil { setFlash(w, "error", "Error saving settings: "+err.Error()) http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) return } setFlash(w, "success", `Let's Encrypt settings saved. Use "Obtain / Renew Now" to test the configuration.`) http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) } // letsEncryptObtainNow triggers an immediate obtain/renew — separate from Save, since // saving configuration must never silently kick off an ACME transaction as a side // effect. This is also how the very first certificate actually gets obtained. func (a *App) letsEncryptObtainNow(w http.ResponseWriter, r *http.Request) { if err := a.ACME.ObtainOrRenew(r.Context()); err != nil { setFlash(w, "error", "Could not obtain certificate: "+err.Error()) } else { setFlash(w, "success", "Certificate obtained successfully") } http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) } // letsEncryptHTTPSave mirrors letsEncryptSave for the [LetsEncryptHTTP] section — a // separate handler (not the generic settingsUpdate reflection) purely so it can // redirect back to /letsencrypt like its DNS-01 sibling; every field here is plain, so // unlike letsEncryptSave there's no secret-redaction concern. func (a *App) letsEncryptHTTPSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { setFlash(w, "error", "Invalid form data") http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) return } sec := a.Cfg.Section("LetsEncryptHTTP") for _, k := range leHTTPFields { sec.Key(k).SetValue(r.FormValue(k)) } if err := a.Cfg.SaveTo(a.ConfigPath); err != nil { setFlash(w, "error", "Error saving settings: "+err.Error()) http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) return } setFlash(w, "success", `Let's Encrypt HTTP-01 settings saved. If you just changed "Enable HTTP-01" or the port, restart the server before using "Obtain / Renew Now" — the challenge responder only starts at boot.`) http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) } // letsEncryptHTTPObtainNow mirrors letsEncryptObtainNow for the HTTP-01 manager. func (a *App) letsEncryptHTTPObtainNow(w http.ResponseWriter, r *http.Request) { if err := a.ACMEHTTP.ObtainOrRenew(r.Context()); err != nil { setFlash(w, "error", "Could not obtain certificate: "+err.Error()) } else { setFlash(w, "success", "Certificate obtained successfully") } http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) } // uploadGCloudServiceAccount mirrors settings.go's uploadTLSFile two-step flow: upload // the file, return its saved path as JSON, and the browser fills a sibling text input // with that path — the path only actually persists once the surrounding form (Save) // is submitted. func (a *App) uploadGCloudServiceAccount(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10 << 20); err != nil { writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Invalid upload"}) return } file, header, err := r.FormFile("gcloud_key_file") if err != nil { writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "No file provided"}) return } defer file.Close() if ext := strings.ToLower(filepath.Ext(header.Filename)); ext != ".json" { writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Expected a .json service account key file"}) return } acmeDir := filepath.Join(filepath.Dir(a.ConfigPath), "server_data", "acme") os.MkdirAll(acmeDir, 0o755) filePath := filepath.Join(acmeDir, fmt.Sprintf("gcloud-sa-%d.json", time.Now().Unix())) out, err := os.Create(filePath) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"status": "error", "message": "Could not save file"}) return } defer out.Close() if _, err := out.ReadFrom(file); err != nil { writeJSON(w, http.StatusInternalServerError, M{"status": "error", "message": "Could not save file"}) return } writeJSON(w, http.StatusOK, M{"status": "success", "filepath": filePath}) } // detectWANIP backs the "Detect" button next to the HTTP-01 manual IP override field — // a live lookup, not persisted anywhere until the surrounding form is saved. func (a *App) detectWANIP(w http.ResponseWriter, r *http.Request) { ip, err := acmecert.DetectWANIP(r.Context()) if err != nil { writeJSON(w, http.StatusBadGateway, M{"status": "error", "message": err.Error()}) return } writeJSON(w, http.StatusOK, M{"status": "success", "ip": ip}) }