149 lines
4.4 KiB
Go
149 lines
4.4 KiB
Go
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
|
|
} |