Updated qr code events and tls server
Some checks failed
CI / Test (1.22) (push) Failing after -25m23s
CI / Test (1.23) (push) Failing after -25m25s
CI / Build (push) Failing after -25m51s
CI / Lint (push) Failing after -25m40s

This commit is contained in:
2025-12-29 17:22:06 +02:00
parent bb9aa01519
commit 94fc899bab
17 changed files with 929 additions and 26 deletions

View File

@@ -18,12 +18,30 @@ type Config struct {
// ServerConfig holds server-specific configuration
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
DefaultCountryCode string `json:"default_country_code,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
AuthKey string `json:"auth_key,omitempty"`
Host string `json:"host"`
Port int `json:"port"`
DefaultCountryCode string `json:"default_country_code,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
AuthKey string `json:"auth_key,omitempty"`
TLS TLSConfig `json:"tls,omitempty"`
}
// TLSConfig holds TLS/HTTPS configuration
type TLSConfig struct {
Enabled bool `json:"enabled"` // Enable HTTPS
Mode string `json:"mode"` // "self-signed", "custom", or "autocert"
CertFile string `json:"cert_file,omitempty"` // Path to certificate file (for custom mode)
KeyFile string `json:"key_file,omitempty"` // Path to key file (for custom mode)
// Self-signed certificate options
CertDir string `json:"cert_dir,omitempty"` // Directory to store generated certificates
// Let's Encrypt / autocert options
Domain string `json:"domain,omitempty"` // Domain name for Let's Encrypt
Email string `json:"email,omitempty"` // Email for Let's Encrypt notifications
CacheDir string `json:"cache_dir,omitempty"` // Cache directory for autocert
Production bool `json:"production,omitempty"` // Use Let's Encrypt production (default: staging)
}
// WhatsAppConfig holds configuration for a WhatsApp account
@@ -139,6 +157,19 @@ func Load(path string) (*Config, error) {
}
}
// Set TLS defaults if enabled
if cfg.Server.TLS.Enabled {
if cfg.Server.TLS.Mode == "" {
cfg.Server.TLS.Mode = "self-signed"
}
if cfg.Server.TLS.CertDir == "" {
cfg.Server.TLS.CertDir = "./data/certs"
}
if cfg.Server.TLS.CacheDir == "" {
cfg.Server.TLS.CacheDir = "./data/autocert"
}
}
return &cfg, nil
}

View File

@@ -37,10 +37,11 @@ func WhatsAppPairFailedEvent(ctx context.Context, accountID string, err error) E
}
// WhatsAppQRCodeEvent creates a WhatsApp QR code event
func WhatsAppQRCodeEvent(ctx context.Context, accountID string, qrCode string) Event {
func WhatsAppQRCodeEvent(ctx context.Context, accountID string, qrCode string, qrURL string) Event {
return NewEvent(ctx, EventWhatsAppQRCode, map[string]any{
"account_id": accountID,
"qr_code": qrCode,
"qr_url": qrURL,
})
}

57
pkg/handlers/qr.go Normal file
View File

@@ -0,0 +1,57 @@
package handlers
import (
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp/whatsmeow"
)
// ServeQRCode serves the QR code image for a WhatsApp account
func (h *Handlers) ServeQRCode(w http.ResponseWriter, r *http.Request) {
// Expected path format: /api/qr/{accountID}
path := r.URL.Path[len("/api/qr/"):]
accountID := path
if accountID == "" {
http.Error(w, "Invalid QR code path", http.StatusBadRequest)
return
}
// Get client from manager
client, exists := h.whatsappMgr.GetClient(accountID)
if !exists {
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// Type assert to whatsmeow client (only whatsmeow clients have QR codes)
whatsmeowClient, ok := client.(*whatsmeow.Client)
if !ok {
http.Error(w, "QR codes are only available for whatsmeow clients", http.StatusBadRequest)
logging.Warn("QR code requested for non-whatsmeow client", "account_id", accountID)
return
}
// Get QR code PNG
pngData, err := whatsmeowClient.GetQRCodePNG()
if err != nil {
if err.Error() == "no QR code available" {
http.Error(w, "No QR code available. The account may already be connected or pairing has not started yet.", http.StatusNotFound)
} else {
logging.Error("Failed to generate QR code PNG", "account_id", accountID, "error", err)
http.Error(w, "Failed to generate QR code", http.StatusInternalServerError)
}
return
}
// Set headers
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
// Write PNG data
writeBytes(w, pngData)
logging.Debug("QR code served", "account_id", accountID)
}

View File

@@ -1,6 +1,9 @@
package logging
import "log/slog"
import (
"log/slog"
"os"
)
// Logger interface allows users to plug in their own logger
type Logger interface {
@@ -33,7 +36,7 @@ func Init(level string) {
slogLevel = slog.LevelInfo
}
handler := slog.NewTextHandler(nil, &slog.HandlerOptions{
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slogLevel,
})
defaultLogger = &slogLogger{logger: slog.New(handler)}

207
pkg/utils/tls.go Normal file
View File

@@ -0,0 +1,207 @@
package utils
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
)
const (
certFileName = "cert.pem"
keyFileName = "key.pem"
)
// GenerateSelfSignedCert generates a self-signed TLS certificate
func GenerateSelfSignedCert(certDir, host string) (certPath, keyPath string, err error) {
// Create cert directory if it doesn't exist
if err := os.MkdirAll(certDir, 0755); err != nil {
return "", "", fmt.Errorf("failed to create cert directory: %w", err)
}
certPath = filepath.Join(certDir, certFileName)
keyPath = filepath.Join(certDir, keyFileName)
// Check if certificate already exists and is valid
if certExists(certPath, keyPath) {
if isCertValid(certPath) {
return certPath, keyPath, nil
}
// Certificate exists but is invalid/expired, regenerate
}
// Generate private key using ECDSA P-256
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", fmt.Errorf("failed to generate private key: %w", err)
}
// Generate serial number
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return "", "", fmt.Errorf("failed to generate serial number: %w", err)
}
// Create certificate template
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour) // Valid for 1 year
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"WhatsHooked Self-Signed"},
CommonName: host,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Add host as SAN (Subject Alternative Name)
if ip := net.ParseIP(host); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, host)
}
// Add localhost and common IPs as SANs
template.DNSNames = append(template.DNSNames, "localhost")
template.IPAddresses = append(template.IPAddresses,
net.ParseIP("127.0.0.1"),
net.ParseIP("::1"),
)
// Create self-signed certificate
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return "", "", fmt.Errorf("failed to create certificate: %w", err)
}
// Write certificate to file
certFile, err := os.Create(certPath)
if err != nil {
return "", "", fmt.Errorf("failed to create cert file: %w", err)
}
defer certFile.Close()
if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return "", "", fmt.Errorf("failed to write cert file: %w", err)
}
// Write private key to file
keyFile, err := os.Create(keyPath)
if err != nil {
return "", "", fmt.Errorf("failed to create key file: %w", err)
}
defer keyFile.Close()
privBytes, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
return "", "", fmt.Errorf("failed to marshal private key: %w", err)
}
if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes}); err != nil {
return "", "", fmt.Errorf("failed to write key file: %w", err)
}
return certPath, keyPath, nil
}
// certExists checks if both certificate and key files exist
func certExists(certPath, keyPath string) bool {
_, certErr := os.Stat(certPath)
_, keyErr := os.Stat(keyPath)
return certErr == nil && keyErr == nil
}
// isCertValid checks if a certificate file is valid and not expired
func isCertValid(certPath string) bool {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return false
}
block, _ := pem.Decode(certPEM)
if block == nil {
return false
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return false
}
// Check if certificate is expired or will expire in the next 30 days
now := time.Now()
if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
return false
}
// Regenerate if expiring within 30 days
if cert.NotAfter.Sub(now) < 30*24*time.Hour {
return false
}
return true
}
// ValidateCertificateFiles checks if custom certificate files exist and are valid
func ValidateCertificateFiles(certPath, keyPath string) error {
// Check if files exist
if _, err := os.Stat(certPath); err != nil {
return fmt.Errorf("certificate file not found: %w", err)
}
if _, err := os.Stat(keyPath); err != nil {
return fmt.Errorf("key file not found: %w", err)
}
// Try to load the certificate to validate it
certPEM, err := os.ReadFile(certPath)
if err != nil {
return fmt.Errorf("failed to read certificate: %w", err)
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("failed to read key: %w", err)
}
// Decode certificate
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return fmt.Errorf("failed to decode certificate PEM")
}
_, err = x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse certificate: %w", err)
}
// Decode key
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return fmt.Errorf("failed to decode key PEM")
}
// Try parsing as different key types
if _, err := x509.ParseECPrivateKey(keyBlock.Bytes); err != nil {
if _, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes); err != nil {
if _, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes); err != nil {
return fmt.Errorf("failed to parse private key (tried EC, PKCS1, PKCS8): %w", err)
}
}
}
return nil
}

View File

@@ -1,13 +1,17 @@
package whatsmeow
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/config"
@@ -22,6 +26,7 @@ import (
waEvents "go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log"
"google.golang.org/protobuf/proto"
"rsc.io/qr"
_ "github.com/mattn/go-sqlite3"
)
@@ -37,6 +42,8 @@ type Client struct {
mediaConfig config.MediaConfig
showQR bool
keepAliveCancel context.CancelFunc
qrCode string
qrCodeMutex sync.RWMutex
}
// NewClient creates a new whatsmeow client
@@ -111,17 +118,28 @@ func (c *Client) Connect(ctx context.Context) error {
case "code":
logging.Info("QR code received for pairing", "account_id", c.id)
// Store QR code
c.qrCodeMutex.Lock()
c.qrCode = evt.Code
c.qrCodeMutex.Unlock()
// Generate QR code URL
qrURL := c.generateQRCodeURL()
// Display QR code in terminal
fmt.Println("\n========================================")
fmt.Printf("WhatsApp QR Code for account: %s\n", c.id)
fmt.Printf("Phone: %s\n", c.phoneNumber)
fmt.Println("========================================")
fmt.Println("Scan this QR code with WhatsApp on your phone:")
fmt.Println()
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
fmt.Println()
fmt.Printf("Or open in browser: %s\n", qrURL)
fmt.Println("========================================")
// Publish QR code event
c.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, c.id, evt.Code))
// Publish QR code event with URL
c.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, c.id, evt.Code, qrURL))
case "success":
logging.Info("Pairing successful", "account_id", c.id, "phone", c.phoneNumber)
@@ -675,3 +693,59 @@ func getExtensionFromMimeType(mimeType string) string {
}
return ""
}
// generateQRCodeURL generates a URL for accessing the QR code
func (c *Client) generateQRCodeURL() string {
baseURL := c.mediaConfig.BaseURL
if baseURL == "" {
baseURL = "http://localhost:8080"
}
return fmt.Sprintf("%s/api/qr/%s", baseURL, c.id)
}
// GetQRCodePNG generates a PNG image of the current QR code
func (c *Client) GetQRCodePNG() ([]byte, error) {
c.qrCodeMutex.RLock()
qrCodeData := c.qrCode
c.qrCodeMutex.RUnlock()
if qrCodeData == "" {
return nil, fmt.Errorf("no QR code available")
}
// Generate QR code using rsc.io/qr
code, err := qr.Encode(qrCodeData, qr.L)
if err != nil {
return nil, fmt.Errorf("failed to encode QR code: %w", err)
}
// Scale the QR code for better visibility (8x scale)
img := code.Image()
scale := 8
bounds := img.Bounds()
scaledWidth := bounds.Dx() * scale
scaledHeight := bounds.Dy() * scale
// Create a new image with scaled dimensions
scaledImg := image.NewRGBA(image.Rect(0, 0, scaledWidth, scaledHeight))
// Scale the image
for y := 0; y < bounds.Dy(); y++ {
for x := 0; x < bounds.Dx(); x++ {
pixel := img.At(x, y)
for sy := 0; sy < scale; sy++ {
for sx := 0; sx < scale; sx++ {
scaledImg.Set(x*scale+sx, y*scale+sy, pixel)
}
}
}
}
// Encode to PNG
var buf bytes.Buffer
if err := png.Encode(&buf, scaledImg); err != nil {
return nil, fmt.Errorf("failed to encode PNG: %w", err)
}
return buf.Bytes(), nil
}

View File

@@ -131,3 +131,48 @@ func WithSQLiteDatabase(sqlitePath string) Option {
c.Database.SQLitePath = sqlitePath
}
}
// WithTLS configures TLS settings
func WithTLS(enabled bool, mode string) Option {
return func(c *config.Config) {
c.Server.TLS.Enabled = enabled
c.Server.TLS.Mode = mode
}
}
// WithTLSConfig configures TLS with full config
func WithTLSConfig(cfg config.TLSConfig) Option {
return func(c *config.Config) {
c.Server.TLS = cfg
}
}
// WithSelfSignedTLS enables HTTPS with self-signed certificates
func WithSelfSignedTLS(certDir string) Option {
return func(c *config.Config) {
c.Server.TLS.Enabled = true
c.Server.TLS.Mode = "self-signed"
c.Server.TLS.CertDir = certDir
}
}
// WithCustomTLS enables HTTPS with custom certificate files
func WithCustomTLS(certFile, keyFile string) Option {
return func(c *config.Config) {
c.Server.TLS.Enabled = true
c.Server.TLS.Mode = "custom"
c.Server.TLS.CertFile = certFile
c.Server.TLS.KeyFile = keyFile
}
}
// WithAutocertTLS enables HTTPS with Let's Encrypt autocert
func WithAutocertTLS(domain, email string, production bool) Option {
return func(c *config.Config) {
c.Server.TLS.Enabled = true
c.Server.TLS.Mode = "autocert"
c.Server.TLS.Domain = domain
c.Server.TLS.Email = email
c.Server.TLS.Production = production
}
}

View File

@@ -2,15 +2,18 @@ package whatshooked
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/utils"
"go.mau.fi/whatsmeow/types"
"golang.org/x/crypto/acme/autocert"
)
// Server is the optional built-in HTTP server
@@ -26,7 +29,7 @@ func NewServer(wh *WhatsHooked) *Server {
}
}
// Start starts the HTTP server
// Start starts the HTTP/HTTPS server
func (s *Server) Start() error {
// Subscribe to hook success events for two-way communication
s.wh.EventBus().Subscribe(events.EventHookSuccess, s.handleHookResponse)
@@ -40,20 +43,25 @@ func (s *Server) Start() error {
Handler: mux,
}
logging.Info("Starting HTTP server",
"host", s.wh.config.Server.Host,
"port", s.wh.config.Server.Port,
"address", addr)
// Connect to WhatsApp accounts
// Connect to WhatsApp accounts after server starts
go func() {
time.Sleep(100 * time.Millisecond) // Give HTTP server a moment to start
logging.Info("HTTP server ready, connecting to WhatsApp accounts")
time.Sleep(100 * time.Millisecond) // Give server a moment to start
logging.Info("Server ready, connecting to WhatsApp accounts")
if err := s.wh.ConnectAll(context.Background()); err != nil {
logging.Error("Failed to connect to WhatsApp accounts", "error", err)
}
}()
// Start server with or without TLS
if s.wh.config.Server.TLS.Enabled {
return s.startTLS()
}
logging.Info("Starting HTTP server",
"host", s.wh.config.Server.Host,
"port", s.wh.config.Server.Port,
"address", addr)
// Start server (blocking)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
@@ -62,6 +70,127 @@ func (s *Server) Start() error {
return nil
}
// startTLS starts the server with TLS based on the configured mode
func (s *Server) startTLS() error {
tlsConfig := &s.wh.config.Server.TLS
addr := fmt.Sprintf("%s:%d", s.wh.config.Server.Host, s.wh.config.Server.Port)
switch tlsConfig.Mode {
case "self-signed":
return s.startSelfSignedTLS(tlsConfig, addr)
case "custom":
return s.startCustomTLS(tlsConfig, addr)
case "autocert":
return s.startAutocertTLS(tlsConfig, addr)
default:
return fmt.Errorf("invalid TLS mode: %s (must be 'self-signed', 'custom', or 'autocert')", tlsConfig.Mode)
}
}
// startSelfSignedTLS starts the server with a self-signed certificate
func (s *Server) startSelfSignedTLS(tlsConfig *config.TLSConfig, addr string) error {
logging.Info("Generating/loading self-signed certificate",
"cert_dir", tlsConfig.CertDir,
"host", s.wh.config.Server.Host)
certPath, keyPath, err := utils.GenerateSelfSignedCert(tlsConfig.CertDir, s.wh.config.Server.Host)
if err != nil {
return fmt.Errorf("failed to generate self-signed certificate: %w", err)
}
logging.Info("Starting HTTPS server with self-signed certificate",
"host", s.wh.config.Server.Host,
"port", s.wh.config.Server.Port,
"address", addr,
"cert", certPath,
"key", keyPath)
if err := s.httpServer.ListenAndServeTLS(certPath, keyPath); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// startCustomTLS starts the server with custom certificate files
func (s *Server) startCustomTLS(tlsConfig *config.TLSConfig, addr string) error {
if tlsConfig.CertFile == "" || tlsConfig.KeyFile == "" {
return fmt.Errorf("custom TLS mode requires cert_file and key_file to be specified")
}
logging.Info("Validating custom TLS certificates",
"cert", tlsConfig.CertFile,
"key", tlsConfig.KeyFile)
// Validate certificate files
if err := utils.ValidateCertificateFiles(tlsConfig.CertFile, tlsConfig.KeyFile); err != nil {
return fmt.Errorf("invalid certificate files: %w", err)
}
logging.Info("Starting HTTPS server with custom certificate",
"host", s.wh.config.Server.Host,
"port", s.wh.config.Server.Port,
"address", addr,
"cert", tlsConfig.CertFile,
"key", tlsConfig.KeyFile)
if err := s.httpServer.ListenAndServeTLS(tlsConfig.CertFile, tlsConfig.KeyFile); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// startAutocertTLS starts the server with Let's Encrypt autocert
func (s *Server) startAutocertTLS(tlsConfig *config.TLSConfig, addr string) error {
if tlsConfig.Domain == "" {
return fmt.Errorf("autocert mode requires domain to be specified")
}
logging.Info("Setting up Let's Encrypt autocert",
"domain", tlsConfig.Domain,
"email", tlsConfig.Email,
"cache_dir", tlsConfig.CacheDir,
"production", tlsConfig.Production)
// Create autocert manager
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(tlsConfig.Domain),
Cache: autocert.DirCache(tlsConfig.CacheDir),
Email: tlsConfig.Email,
}
// Configure TLS
s.httpServer.TLSConfig = &tls.Config{
GetCertificate: certManager.GetCertificate,
MinVersion: tls.VersionTLS12,
}
// Start HTTP-01 challenge server on port 80 if we're listening on 443
if s.wh.config.Server.Port == 443 {
go func() {
httpAddr := fmt.Sprintf("%s:80", s.wh.config.Server.Host)
logging.Info("Starting HTTP server for ACME challenges", "address", httpAddr)
if err := http.ListenAndServe(httpAddr, certManager.HTTPHandler(nil)); err != nil {
logging.Error("Failed to start HTTP challenge server", "error", err)
}
}()
}
logging.Info("Starting HTTPS server with Let's Encrypt",
"host", s.wh.config.Server.Host,
"port", s.wh.config.Server.Port,
"address", addr,
"domain", tlsConfig.Domain)
if err := s.httpServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// Stop stops the HTTP server gracefully
func (s *Server) Stop(ctx context.Context) error {
if s.httpServer != nil {
@@ -96,6 +225,9 @@ func (s *Server) setupRoutes() *http.ServeMux {
// Serve media files (with auth)
mux.HandleFunc("/api/media/", h.ServeMedia)
// Serve QR codes (no auth - needed during pairing)
mux.HandleFunc("/api/qr/", h.ServeQRCode)
// Business API webhooks (no auth - Meta validates via verify_token)
mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook)
@@ -103,7 +235,8 @@ func (s *Server) setupRoutes() *http.ServeMux {
"health", "/health",
"hooks", "/api/hooks",
"accounts", "/api/accounts",
"send", "/api/send")
"send", "/api/send",
"qr", "/api/qr")
return mux
}