feat: add AnyIP certificate download with cache and fallback chain
This commit is contained in:
@@ -5,12 +5,9 @@ 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.
|
||||||
@@ -19,21 +16,21 @@ type Server struct {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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 leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
|
||||||
if time.Now().Before(leaf.NotAfter) && certCoversIP(leaf, lanIP) {
|
if time.Now().Before(leaf.NotAfter) && certCoversIP(leaf, lanIP) {
|
||||||
slog.Info("using cached TLS certificate", "expires", leaf.NotAfter.Format("2006-01-02"))
|
slog.Info("using cached self-signed 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}},
|
||||||
|
Host: lanIP,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new self-signed cert
|
// 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
36
main.go
@@ -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)
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s://%s:%d/?token=%s", scheme, lanIP, cfg.Server.Port, token)
|
scheme = "https"
|
||||||
|
host = tlsResult.Host
|
||||||
|
}
|
||||||
|
// 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user