package tls import ( "crypto/tls" "crypto/x509" "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "strings" "time" ) // certDir returns the platform-appropriate cache directory for certificates. func certDir() string { base, err := os.UserCacheDir() if err != nil { base, _ = os.UserHomeDir() base = filepath.Join(base, ".cache") } dir := filepath.Join(base, "voicepaste", "certs") os.MkdirAll(dir, 0700) return dir } // Result holds the TLS config and the AnyIP hostname. type Result struct { Config *tls.Config Host string // AnyIP hostname (e.g. voicepaste-192-168-1-5.anyip.dev) } // AnyIPHost returns the AnyIP hostname for a given LAN IP. // e.g. 192.168.1.5 → voicepaste-192-168-1-5.anyip.dev func AnyIPHost(lanIP string) string { dashed := strings.ReplaceAll(lanIP, ".", "-") return fmt.Sprintf("voicepaste-%s.anyip.dev", dashed) } // GetTLSConfig returns a TLS config using AnyIP wildcard certificate. // It tries cached cert first, then downloads fresh if needed. func GetTLSConfig(lanIP string) (*Result, error) { dir := certDir() anyipDir := filepath.Join(dir, "anyip") os.MkdirAll(anyipDir, 0700) certFile := filepath.Join(anyipDir, "fullchain.pem") keyFile := filepath.Join(anyipDir, "privkey.pem") host := AnyIPHost(lanIP) // Try cached cert first if cert, err := loadAndValidateCert(certFile, keyFile); err == nil { slog.Info("using cached AnyIP certificate") return &Result{ Config: &tls.Config{Certificates: []tls.Certificate{cert}}, Host: host, }, nil } // Download fresh cert slog.Info("downloading AnyIP certificate") if err := downloadAnyIPCert(certFile, keyFile); err != nil { return nil, fmt.Errorf("failed to download AnyIP certificate: %w", err) } cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { return nil, fmt.Errorf("failed to load downloaded certificate: %w", err) } slog.Info("downloaded fresh AnyIP certificate") return &Result{ Config: &tls.Config{Certificates: []tls.Certificate{cert}}, Host: host, }, nil } // loadAndValidateCert loads a certificate and validates it's not expired. func loadAndValidateCert(certFile, keyFile string) (tls.Certificate, error) { cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { return tls.Certificate{}, err } leaf, err := x509.ParseCertificate(cert.Certificate[0]) if err != nil { return tls.Certificate{}, err } // Check if cert expires within 24 hours if time.Now().After(leaf.NotAfter.Add(-24 * time.Hour)) { return tls.Certificate{}, fmt.Errorf("certificate expired or expiring soon") } return cert, nil } // downloadAnyIPCert downloads the AnyIP wildcard cert and key. func downloadAnyIPCert(certFile, keyFile string) error { client := &http.Client{Timeout: 15 * time.Second} if err := downloadFile(client, "https://anyip.dev/cert/fullchain.pem", certFile); err != nil { return fmt.Errorf("download fullchain: %w", err) } if err := downloadFile(client, "https://anyip.dev/cert/privkey.pem", keyFile); err != nil { os.Remove(certFile) // clean up partial download return fmt.Errorf("download privkey: %w", err) } return nil } func downloadFile(client *http.Client, url, dest string) error { resp, err := client.Get(url) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) } f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return err } defer f.Close() _, err = io.Copy(f, resp.Body) return err }