feat: add AnyIP certificate download with cache and fallback chain

This commit is contained in:
2026-03-01 03:20:31 +08:00
parent 35032c1777
commit 8b9070aac8
3 changed files with 146 additions and 50 deletions

View File

@@ -5,35 +5,32 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"log/slog" "log/slog"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/static" "github.com/gofiber/fiber/v3/middleware/static"
"github.com/imbytecat/voicepaste/internal/config" "github.com/imbytecat/voicepaste/internal/config"
vpTLS "github.com/imbytecat/voicepaste/internal/tls"
) )
// Server holds the Fiber app and related state. // Server holds the Fiber app and related state.
type Server struct { type Server struct {
app *fiber.App app *fiber.App
token string token string
lanIP string lanIP string
webFS fs.FS webFS fs.FS
tlsCfg *tls.Config // nil = no TLS
} }
// New creates a new Server instance. // 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{ app := fiber.New(fiber.Config{
AppName: "VoicePaste", AppName: "VoicePaste",
}) })
s := &Server{ s := &Server{
app: app, app: app,
token: token, token: token,
lanIP: lanIP, lanIP: lanIP,
webFS: webFS, webFS: webFS,
tlsCfg: tlsCfg,
} }
s.setupRoutes() s.setupRoutes()
return s return s
} }
@@ -66,21 +63,15 @@ func (s *Server) Token() string {
func (s *Server) Start() error { func (s *Server) Start() error {
cfg := config.Get() cfg := config.Get()
addr := fmt.Sprintf(":%d", cfg.Server.Port) addr := fmt.Sprintf(":%d", cfg.Server.Port)
if s.tlsCfg != nil {
if cfg.Server.TLSAuto {
tlsCfg, err := vpTLS.GetTLSConfig(s.lanIP)
if err != nil {
return fmt.Errorf("TLS setup failed: %w", err)
}
slog.Info("starting HTTPS server", "addr", addr) slog.Info("starting HTTPS server", "addr", addr)
return s.app.Listen(addr, fiber.ListenConfig{ return s.app.Listen(addr, fiber.ListenConfig{
TLSConfig: &tls.Config{ TLSConfig: &tls.Config{
Certificates: tlsCfg.Certificates, Certificates: s.tlsCfg.Certificates,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
}, },
}) })
} }
slog.Info("starting HTTP server (no TLS)", "addr", addr) slog.Info("starting HTTP server (no TLS)", "addr", addr)
return s.app.Listen(addr) return s.app.Listen(addr)
} }

View File

@@ -4,47 +4,104 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net" "net"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
) )
// certDir returns the directory for storing certificates. // certDir returns the platform-appropriate cache directory for certificates.
func certDir() string { func certDir() string {
home, _ := os.UserHomeDir() base, err := os.UserCacheDir()
dir := filepath.Join(home, ".voicepaste", "certs") if err != nil {
base, _ = os.UserHomeDir()
base = filepath.Join(base, ".cache")
}
dir := filepath.Join(base, "voicepaste", "certs")
os.MkdirAll(dir, 0700) os.MkdirAll(dir, 0700)
return dir return dir
} }
// GetTLSConfig returns a tls.Config for the given LAN IP. // Result holds the TLS config and metadata about which cert source was used.
// It tries to load cached self-signed certs, or generates new ones. type Result struct {
func GetTLSConfig(lanIP string) (*tls.Config, error) { Config *tls.Config
dir := certDir() AnyIP bool // true if AnyIP cert is active
certFile := filepath.Join(dir, "cert.pem") Host string // hostname to use in URLs (AnyIP domain or raw IP)
keyFile := filepath.Join(dir, "key.pem") }
// Try loading existing cert // AnyIPHost returns the AnyIP hostname for a given LAN IP.
if cert, err := tls.LoadX509KeyPair(certFile, keyFile); err == nil { // e.g. 192.168.1.5 → voicepaste-192-168-1-5.anyip.dev
// Check if cert covers this IP and is not expired 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 leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
if time.Now().Before(leaf.NotAfter) && certCoversIP(leaf, lanIP) { if time.Now().Before(leaf.NotAfter.Add(-24 * time.Hour)) { // 1 day buffer
slog.Info("using cached TLS certificate", "expires", leaf.NotAfter.Format("2006-01-02")) slog.Info("using cached AnyIP certificate", "expires", leaf.NotAfter.Format("2006-01-02"))
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil 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) slog.Info("generating self-signed TLS certificate", "ip", lanIP)
cert, err := generateSelfSigned(lanIP, certFile, keyFile) cert, err := generateSelfSigned(lanIP, ssCert, ssKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("generate TLS cert: %w", err) return nil, fmt.Errorf("generate TLS cert: %w", err)
} }
return &Result{
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil Config: &tls.Config{Certificates: []tls.Certificate{cert}},
Host: lanIP,
}, nil
} }
// certCoversIP checks if the certificate covers the given IP. // certCoversIP checks if the certificate covers the given IP.
@@ -60,3 +117,33 @@ func certCoversIP(cert *x509.Certificate, ip string) bool {
} }
return false 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
}

36
main.go
View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
crypto_tls "crypto/tls"
"embed" "embed"
"fmt" "fmt"
"io/fs" "io/fs"
@@ -12,6 +13,7 @@ import (
"github.com/imbytecat/voicepaste/internal/config" "github.com/imbytecat/voicepaste/internal/config"
"github.com/imbytecat/voicepaste/internal/paste" "github.com/imbytecat/voicepaste/internal/paste"
"github.com/imbytecat/voicepaste/internal/server" "github.com/imbytecat/voicepaste/internal/server"
vpTLS "github.com/imbytecat/voicepaste/internal/tls"
"github.com/imbytecat/voicepaste/internal/ws" "github.com/imbytecat/voicepaste/internal/ws"
) )
@@ -50,13 +52,22 @@ func main() {
// Generate auth token // Generate auth token
token := server.GenerateToken() token := server.GenerateToken()
// Build URL // TLS setup
scheme := "https" var tlsResult *vpTLS.Result
if !cfg.Server.TLSAuto { scheme := "http"
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 // Print connection info
fmt.Println() fmt.Println()
fmt.Println("╔══════════════════════════════════════╗") fmt.Println("╔══════════════════════════════════════╗")
@@ -64,17 +75,24 @@ func main() {
fmt.Println("╚══════════════════════════════════════╝") fmt.Println("╚══════════════════════════════════════╝")
fmt.Println() fmt.Println()
fmt.Printf(" URL: %s\n", url) 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() fmt.Println()
printQRCode(url) printQRCode(url)
fmt.Println() fmt.Println()
fmt.Println(" Scan QR code with your phone to connect.") fmt.Println(" Scan QR code with your phone to connect.")
fmt.Println(" Press Ctrl+C to stop.") fmt.Println(" Press Ctrl+C to stop.")
fmt.Println() fmt.Println()
// Create and start server // Create and start server
webContent, _ := fs.Sub(webFS, "web") 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 // Build ASR factory from config
asrCfg := asr.Config{ asrCfg := asr.Config{
AppKey: cfg.Doubao.AppKey, AppKey: cfg.Doubao.AppKey,