package tls import ( "crypto/tls" "crypto/x509" "fmt" "io" "log/slog" "net" "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 metadata about which cert source was used. type Result struct { Config *tls.Config AnyIP bool // true if AnyIP cert is active Host string // hostname to use in URLs (AnyIP domain or raw IP) } // 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 for the given LAN IP. // Priority: cached AnyIP → download AnyIP → cached self-signed → generate self-signed. func GetTLSConfig(lanIP string) (*Result, error) { dir := certDir() anyipDir := filepath.Join(dir, "anyip") os.MkdirAll(anyipDir, 0700) anyipCert := filepath.Join(anyipDir, "fullchain.pem") anyipKey := filepath.Join(anyipDir, "privkey.pem") // 1. Try cached AnyIP cert if cert, err := tls.LoadX509KeyPair(anyipCert, anyipKey); err == nil { if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil { if time.Now().Before(leaf.NotAfter.Add(-24 * time.Hour)) { // 1 day buffer slog.Info("using cached AnyIP certificate", "expires", leaf.NotAfter.Format("2006-01-02")) return &Result{ Config: &tls.Config{Certificates: []tls.Certificate{cert}}, AnyIP: true, Host: AnyIPHost(lanIP), }, nil } } } // 2. Try downloading AnyIP cert if err := downloadAnyIPCert(anyipCert, anyipKey); err == nil { if cert, err := tls.LoadX509KeyPair(anyipCert, anyipKey); err == nil { slog.Info("downloaded fresh AnyIP certificate") return &Result{ Config: &tls.Config{Certificates: []tls.Certificate{cert}}, AnyIP: true, Host: AnyIPHost(lanIP), }, nil } } else { slog.Warn("AnyIP cert download failed, falling back to self-signed", "err", err) } // 3. Try cached self-signed ssCert := filepath.Join(dir, "cert.pem") ssKey := filepath.Join(dir, "key.pem") if cert, err := tls.LoadX509KeyPair(ssCert, ssKey); err == nil { if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil { if time.Now().Before(leaf.NotAfter) && certCoversIP(leaf, lanIP) { slog.Info("using cached self-signed certificate", "expires", leaf.NotAfter.Format("2006-01-02")) return &Result{ Config: &tls.Config{Certificates: []tls.Certificate{cert}}, Host: lanIP, }, nil } } } // 4. Generate self-signed slog.Info("generating self-signed TLS certificate", "ip", lanIP) cert, err := generateSelfSigned(lanIP, ssCert, ssKey) if err != nil { return nil, fmt.Errorf("generate TLS cert: %w", err) } return &Result{ Config: &tls.Config{Certificates: []tls.Certificate{cert}}, Host: lanIP, }, nil } // certCoversIP checks if the certificate covers the given IP. func certCoversIP(cert *x509.Certificate, ip string) bool { target := net.ParseIP(ip) if target == nil { return false } for _, certIP := range cert.IPAddresses { if certIP.Equal(target) { return true } } return false } // 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 }