From 8b9070aac826842586286a3f727a533d9eba6577 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Sun, 1 Mar 2026 03:20:31 +0800 Subject: [PATCH] feat: add AnyIP certificate download with cache and fallback chain --- internal/server/server.go | 35 ++++------- internal/tls/tls.go | 125 ++++++++++++++++++++++++++++++++------ main.go | 36 ++++++++--- 3 files changed, 146 insertions(+), 50 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 90d5a18..ed4c001 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,35 +5,32 @@ import ( "fmt" "io/fs" "log/slog" - "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/static" - "github.com/imbytecat/voicepaste/internal/config" - vpTLS "github.com/imbytecat/voicepaste/internal/tls" ) // Server holds the Fiber app and related state. type Server struct { - app *fiber.App - token string - lanIP string - webFS fs.FS + app *fiber.App + token string + lanIP string + webFS fs.FS + tlsCfg *tls.Config // nil = no TLS } // New creates a new Server instance. -func New(token, lanIP string, webFS fs.FS) *Server { +func New(token, lanIP string, webFS fs.FS, tlsCfg *tls.Config) *Server { app := fiber.New(fiber.Config{ AppName: "VoicePaste", }) - s := &Server{ - app: app, - token: token, - lanIP: lanIP, - webFS: webFS, + app: app, + token: token, + lanIP: lanIP, + webFS: webFS, + tlsCfg: tlsCfg, } - s.setupRoutes() return s } @@ -66,21 +63,15 @@ func (s *Server) Token() string { func (s *Server) Start() error { cfg := config.Get() addr := fmt.Sprintf(":%d", cfg.Server.Port) - - if cfg.Server.TLSAuto { - tlsCfg, err := vpTLS.GetTLSConfig(s.lanIP) - if err != nil { - return fmt.Errorf("TLS setup failed: %w", err) - } + if s.tlsCfg != nil { slog.Info("starting HTTPS server", "addr", addr) return s.app.Listen(addr, fiber.ListenConfig{ TLSConfig: &tls.Config{ - Certificates: tlsCfg.Certificates, + Certificates: s.tlsCfg.Certificates, MinVersion: tls.VersionTLS12, }, }) } - slog.Info("starting HTTP server (no TLS)", "addr", addr) return s.app.Listen(addr) } diff --git a/internal/tls/tls.go b/internal/tls/tls.go index 1b7c9ba..12c53d3 100644 --- a/internal/tls/tls.go +++ b/internal/tls/tls.go @@ -4,47 +4,104 @@ import ( "crypto/tls" "crypto/x509" "fmt" + "io" "log/slog" "net" + "net/http" "os" "path/filepath" + "strings" "time" ) -// certDir returns the directory for storing certificates. +// certDir returns the platform-appropriate cache directory for certificates. func certDir() string { - home, _ := os.UserHomeDir() - dir := filepath.Join(home, ".voicepaste", "certs") + 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 } -// GetTLSConfig returns a tls.Config for the given LAN IP. -// It tries to load cached self-signed certs, or generates new ones. -func GetTLSConfig(lanIP string) (*tls.Config, error) { - dir := certDir() - certFile := filepath.Join(dir, "cert.pem") - keyFile := filepath.Join(dir, "key.pem") +// 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) +} - // Try loading existing cert - if cert, err := tls.LoadX509KeyPair(certFile, keyFile); err == nil { - // Check if cert covers this IP and is not expired +// 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) && certCoversIP(leaf, lanIP) { - slog.Info("using cached TLS certificate", "expires", leaf.NotAfter.Format("2006-01-02")) - return &tls.Config{Certificates: []tls.Certificate{cert}}, 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 } } } - // Generate new self-signed cert + // 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, certFile, keyFile) + cert, err := generateSelfSigned(lanIP, ssCert, ssKey) if err != nil { return nil, fmt.Errorf("generate TLS cert: %w", err) } - - return &tls.Config{Certificates: []tls.Certificate{cert}}, nil + return &Result{ + Config: &tls.Config{Certificates: []tls.Certificate{cert}}, + Host: lanIP, + }, nil } // certCoversIP checks if the certificate covers the given IP. @@ -60,3 +117,33 @@ func certCoversIP(cert *x509.Certificate, ip string) bool { } 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 +} \ No newline at end of file diff --git a/main.go b/main.go index e179e17..463bddc 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + crypto_tls "crypto/tls" "embed" "fmt" "io/fs" @@ -12,6 +13,7 @@ import ( "github.com/imbytecat/voicepaste/internal/config" "github.com/imbytecat/voicepaste/internal/paste" "github.com/imbytecat/voicepaste/internal/server" + vpTLS "github.com/imbytecat/voicepaste/internal/tls" "github.com/imbytecat/voicepaste/internal/ws" ) @@ -50,13 +52,22 @@ func main() { // Generate auth token token := server.GenerateToken() - // Build URL - scheme := "https" - if !cfg.Server.TLSAuto { - scheme = "http" + // TLS setup + var tlsResult *vpTLS.Result + scheme := "http" + host := lanIP + if cfg.Server.TLSAuto { + var err error + tlsResult, err = vpTLS.GetTLSConfig(lanIP) + if err != nil { + slog.Error("TLS setup failed", "error", err) + os.Exit(1) + } + scheme = "https" + host = tlsResult.Host } - url := fmt.Sprintf("%s://%s:%d/?token=%s", scheme, lanIP, cfg.Server.Port, token) - + // Build URL + url := fmt.Sprintf("%s://%s:%d/?token=%s", scheme, host, cfg.Server.Port, token) // Print connection info fmt.Println() fmt.Println("╔══════════════════════════════════════╗") @@ -64,17 +75,24 @@ func main() { fmt.Println("╚══════════════════════════════════════╝") fmt.Println() fmt.Printf(" URL: %s\n", url) + if tlsResult != nil && tlsResult.AnyIP { + fmt.Println(" TLS: AnyIP (browser-trusted)") + } else if cfg.Server.TLSAuto { + fmt.Println(" TLS: self-signed (browser will warn)") + } fmt.Println() printQRCode(url) fmt.Println() fmt.Println(" Scan QR code with your phone to connect.") fmt.Println(" Press Ctrl+C to stop.") fmt.Println() - // Create and start server webContent, _ := fs.Sub(webFS, "web") - srv := server.New(token, lanIP, webContent) - + var serverTLSCfg *crypto_tls.Config + if tlsResult != nil { + serverTLSCfg = tlsResult.Config + } + srv := server.New(token, lanIP, webContent, serverTLSCfg) // Build ASR factory from config asrCfg := asr.Config{ AppKey: cfg.Doubao.AppKey,