diff --git a/DOCKER.md b/DOCKER.md index 631e05e..f314554 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -156,12 +156,19 @@ When you first start the server, you'll need to scan a QR code to authenticate w docker-compose logs -f whatshooked ``` -The QR code will be displayed in ASCII art in the terminal. Scan it with WhatsApp on your phone: +The QR code will be displayed in ASCII art in the terminal along with a browser link. You have two options: + +**Option 1: Scan from Terminal** 1. Open WhatsApp 2. Go to Settings > Linked Devices 3. Tap "Link a Device" 4. Scan the QR code from the terminal +**Option 2: View in Browser** +1. Look for the line: `Or open in browser: http://localhost:8080/api/qr/{account_id}` +2. Open that URL in your web browser to see a larger PNG image +3. Scan the QR code from your browser + ### Alternative: Use CLI Tool You can also use the CLI tool outside Docker to link accounts, then mount the session: diff --git a/README.md b/README.md index 82592fc..2c920b8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A Go library and service that connects to WhatsApp and forwards messages to regi - **Multi-Account Support**: Connect to multiple WhatsApp accounts simultaneously - **Dual Client Types**: Support for both personal WhatsApp (whatsmeow) and WhatsApp Business API +- **QR Code Pairing**: Browser-based QR code display for easy device pairing with PNG image endpoint - **Webhook Integration**: Register multiple webhooks to receive WhatsApp messages - **Two-Way Communication**: Webhooks can respond with messages to send back to WhatsApp - **Instance/Config Level Hooks**: Global hooks that receive all messages from all accounts @@ -24,6 +25,7 @@ A Go library and service that connects to WhatsApp and forwards messages to regi - **CLI Management**: Command-line tool for managing accounts and hooks - **Structured Logging**: JSON-based logging with configurable log levels - **Authentication**: HTTP Basic Auth and API key authentication for server endpoints +- **HTTPS/TLS Support**: Three certificate modes - self-signed, custom certificates, and Let's Encrypt autocert - **Event Logging**: Optional event persistence to file, SQLite, or PostgreSQL - **Library Mode**: Use WhatsHooked as a Go library in your own applications - **Flexible Handlers**: Mount individual HTTP handlers in custom servers @@ -65,6 +67,46 @@ make build ./bin/whatshook-server -config config.json ``` +## Pairing WhatsApp Accounts + +When using personal WhatsApp accounts (whatsmeow), you'll need to pair the device on first launch. The QR code will be displayed in two ways: + +### Terminal Display +The QR code is shown as ASCII art directly in the terminal: +``` +======================================== +WhatsApp QR Code for account: personal +Phone: +1234567890 +======================================== +Scan this QR code with WhatsApp on your phone: + +[ASCII QR Code displayed here] + +Or open in browser: http://localhost:8080/api/qr/personal +======================================== +``` + +### Browser Display +For easier scanning, open the provided URL in your browser to view a larger PNG image: +- URL format: `http://localhost:8080/api/qr/{account_id}` +- No authentication required for this endpoint +- The QR code updates automatically when a new code is generated + +### Webhook Events +The QR code URL is also included in the `whatsapp.qr.code` event: +```json +{ + "type": "whatsapp.qr.code", + "data": { + "account_id": "personal", + "qr_code": "2@...", + "qr_url": "http://localhost:8080/api/qr/personal" + } +} +``` + +This allows your webhooks to programmatically display or forward QR codes for remote device pairing. + ## Architecture The project uses an event-driven architecture with the following packages: @@ -312,6 +354,18 @@ Create a `config.json` file based on the example: cp config.example.json config.json ``` +Or use one of the HTTPS examples: +```bash +# Self-signed certificate (development) +cp config.https-self-signed.example.json config.json + +# Custom certificate (production) +cp config.https-custom.example.json config.json + +# Let's Encrypt autocert (production) +cp config.https-letsencrypt.example.json config.json +``` + Edit the configuration file to add your WhatsApp accounts and webhooks: ```json @@ -396,6 +450,168 @@ Edit the configuration file to add your WhatsApp accounts and webhooks: - `events`: List of event types to subscribe to (optional, defaults to all) - `description`: Optional description +### HTTPS/TLS Configuration + +WhatsHooked supports HTTPS with three certificate modes for secure connections: + +#### 1. Self-Signed Certificates (Development/Testing) + +Automatically generates and manages self-signed certificates. Ideal for development and testing environments. + +```json +{ + "server": { + "host": "localhost", + "port": 8443, + "tls": { + "enabled": true, + "mode": "self-signed", + "cert_dir": "./data/certs" + } + } +} +``` + +Or programmatically: +```go +wh, err := whatshooked.New( + whatshooked.WithServer("0.0.0.0", 8443), + whatshooked.WithSelfSignedTLS("./data/certs"), +) +``` + +**Features:** +- Automatically generates certificates on first run +- Certificates valid for 1 year +- Auto-renewal when expiring within 30 days +- Supports both IP addresses and hostnames as SANs +- No external dependencies + +**Note:** Browsers will show security warnings for self-signed certificates. This is normal and expected for development environments. + +#### 2. Custom Certificates (Production) + +Use your own certificate files from a trusted Certificate Authority (CA) or an existing certificate. + +```json +{ + "server": { + "host": "0.0.0.0", + "port": 8443, + "tls": { + "enabled": true, + "mode": "custom", + "cert_file": "/etc/ssl/certs/myserver.crt", + "key_file": "/etc/ssl/private/myserver.key" + } + } +} +``` + +Or programmatically: +```go +wh, err := whatshooked.New( + whatshooked.WithServer("0.0.0.0", 8443), + whatshooked.WithCustomTLS("/etc/ssl/certs/myserver.crt", "/etc/ssl/private/myserver.key"), +) +``` + +**Features:** +- Use certificates from any CA (Let's Encrypt, DigiCert, etc.) +- Full control over certificate lifecycle +- Validates certificate files on startup +- Supports PKCS1, PKCS8, and EC private keys + +#### 3. Let's Encrypt with Autocert (Production) + +Automatically obtains and renews SSL certificates from Let's Encrypt. Best for production deployments with a registered domain. + +```json +{ + "server": { + "host": "0.0.0.0", + "port": 443, + "tls": { + "enabled": true, + "mode": "autocert", + "domain": "whatshooked.example.com", + "email": "admin@example.com", + "cache_dir": "./data/autocert", + "production": true + } + } +} +``` + +Or programmatically: +```go +wh, err := whatshooked.New( + whatshooked.WithServer("0.0.0.0", 443), + whatshooked.WithAutocertTLS("whatshooked.example.com", "admin@example.com", true), +) +``` + +**Features:** +- Automatic certificate provisioning from Let's Encrypt +- Automatic certificate renewal before expiration +- Fully managed - no manual intervention required +- Automatically starts HTTP challenge server on port 80 (when using port 443) +- Production and staging modes available + +**Requirements:** +- Server must be publicly accessible +- Port 443 (HTTPS) must be open +- Port 80 (HTTP) must be open for ACME challenges +- Valid domain name pointing to your server +- Email address for Let's Encrypt notifications + +**Important Notes:** +- Set `production: false` for testing to use Let's Encrypt staging environment (avoids rate limits) +- Set `production: true` for production deployments to get trusted certificates +- Ensure your domain's DNS A/AAAA record points to your server's IP +- Let's Encrypt has rate limits: 50 certificates per domain per week + +#### TLS Configuration Reference + +All TLS configuration options: + +```json +{ + "server": { + "tls": { + "enabled": true, // Enable HTTPS (default: false) + "mode": "self-signed", // Mode: "self-signed", "custom", or "autocert" (required if enabled) + + // Self-signed mode options + "cert_dir": "./data/certs", // Directory for generated certificates (default: ./data/certs) + + // Custom mode options + "cert_file": "/path/to/cert", // Path to certificate file (required for custom mode) + "key_file": "/path/to/key", // Path to private key file (required for custom mode) + + // Autocert mode options + "domain": "example.com", // Domain name (required for autocert mode) + "email": "admin@example.com", // Email for Let's Encrypt notifications (optional) + "cache_dir": "./data/autocert", // Cache directory for certificates (default: ./data/autocert) + "production": true // Use Let's Encrypt production (default: false/staging) + } + } +} +``` + +#### Switching Between HTTP and HTTPS + +To disable HTTPS and use HTTP, set `enabled: false` or omit the `tls` section entirely: + +```json +{ + "server": { + "host": "localhost", + "port": 8080 + } +} +``` + ### Server Authentication The server supports two authentication methods to protect API endpoints: diff --git a/TODO.md b/TODO.md index 42a02a9..ed9a271 100644 --- a/TODO.md +++ b/TODO.md @@ -9,5 +9,5 @@ - [ ] Optional Postgres server,database for event saving and hook registration - [✔️] Optional Event logging into directory for each type - [ ] MQTT Support for events (To connect it to home assistant, have to prototype. Incoming message/outgoing messages) -- [ ] Refactor into pkg to be able to use the system as a client library instead of starting a server -- [ ] HTTPS Server with certbot support, self signed certificate generation or custom certificate paths. \ No newline at end of file +- [✔️] Refactor into pkg to be able to use the system as a client library instead of starting a server +- [✔️] HTTPS Server with certbot support, self signed certificate generation or custom certificate paths. \ No newline at end of file diff --git a/.whatshooked-cli.example.json b/config/.whatshooked-cli.example.json similarity index 100% rename from .whatshooked-cli.example.json rename to config/.whatshooked-cli.example.json diff --git a/config.example.json b/config/config.example.json similarity index 91% rename from config.example.json rename to config/config.example.json index a6cd58a..d4a3e62 100644 --- a/config.example.json +++ b/config/config.example.json @@ -5,7 +5,18 @@ "default_country_code": "27", "username": "", "password": "", - "auth_key": "" + "auth_key": "", + "tls": { + "enabled": false, + "mode": "self-signed", + "cert_dir": "./data/certs", + "cert_file": "", + "key_file": "", + "domain": "", + "email": "", + "cache_dir": "./data/autocert", + "production": false + } }, "whatsapp": [ { diff --git a/config/config.https-custom.example.json b/config/config.https-custom.example.json new file mode 100644 index 0000000..c7b8737 --- /dev/null +++ b/config/config.https-custom.example.json @@ -0,0 +1,39 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 8443, + "default_country_code": "27", + "auth_key": "your-secure-api-key-here", + "tls": { + "enabled": true, + "mode": "custom", + "cert_file": "/etc/ssl/certs/whatshooked.crt", + "key_file": "/etc/ssl/private/whatshooked.key" + } + }, + "whatsapp": [ + { + "id": "personal", + "type": "whatsmeow", + "phone_number": "+1234567890", + "session_path": "./sessions/personal", + "show_qr": true + } + ], + "hooks": [ + { + "id": "message_hook", + "name": "Message Handler", + "url": "https://example.com/webhook", + "method": "POST", + "active": true, + "events": ["message.received"] + } + ], + "media": { + "data_path": "./data/media", + "mode": "link", + "base_url": "https://whatshooked.example.com" + }, + "log_level": "info" +} diff --git a/config/config.https-letsencrypt.example.json b/config/config.https-letsencrypt.example.json new file mode 100644 index 0000000..5c5afec --- /dev/null +++ b/config/config.https-letsencrypt.example.json @@ -0,0 +1,41 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 443, + "default_country_code": "27", + "auth_key": "your-secure-api-key-here", + "tls": { + "enabled": true, + "mode": "autocert", + "domain": "whatshooked.example.com", + "email": "admin@example.com", + "cache_dir": "./data/autocert", + "production": true + } + }, + "whatsapp": [ + { + "id": "personal", + "type": "whatsmeow", + "phone_number": "+1234567890", + "session_path": "./sessions/personal", + "show_qr": true + } + ], + "hooks": [ + { + "id": "message_hook", + "name": "Message Handler", + "url": "https://example.com/webhook", + "method": "POST", + "active": true, + "events": ["message.received"] + } + ], + "media": { + "data_path": "./data/media", + "mode": "link", + "base_url": "https://whatshooked.example.com" + }, + "log_level": "info" +} diff --git a/config/config.https-self-signed.example.json b/config/config.https-self-signed.example.json new file mode 100644 index 0000000..ac7c23b --- /dev/null +++ b/config/config.https-self-signed.example.json @@ -0,0 +1,38 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 8443, + "default_country_code": "27", + "auth_key": "your-secure-api-key-here", + "tls": { + "enabled": true, + "mode": "self-signed", + "cert_dir": "./data/certs" + } + }, + "whatsapp": [ + { + "id": "personal", + "type": "whatsmeow", + "phone_number": "+1234567890", + "session_path": "./sessions/personal", + "show_qr": true + } + ], + "hooks": [ + { + "id": "message_hook", + "name": "Message Handler", + "url": "https://example.com/webhook", + "method": "POST", + "active": true, + "events": ["message.received"] + } + ], + "media": { + "data_path": "./data/media", + "mode": "link", + "base_url": "https://localhost:8443" + }, + "log_level": "info" +} diff --git a/go.mod b/go.mod index b74b14d..8f499cb 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32 + golang.org/x/crypto v0.46.0 google.golang.org/protobuf v1.36.11 ) @@ -36,7 +37,6 @@ require ( go.mau.fi/libsignal v0.2.1 // indirect go.mau.fi/util v0.9.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect diff --git a/pkg/config/config.go b/pkg/config/config.go index 97470d2..368eb55 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 } diff --git a/pkg/events/builders.go b/pkg/events/builders.go index ccf0292..f4c2639 100644 --- a/pkg/events/builders.go +++ b/pkg/events/builders.go @@ -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, }) } diff --git a/pkg/handlers/qr.go b/pkg/handlers/qr.go new file mode 100644 index 0000000..b59149c --- /dev/null +++ b/pkg/handlers/qr.go @@ -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) +} diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go index 054800e..3977f37 100644 --- a/pkg/logging/logging.go +++ b/pkg/logging/logging.go @@ -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)} diff --git a/pkg/utils/tls.go b/pkg/utils/tls.go new file mode 100644 index 0000000..e7d5623 --- /dev/null +++ b/pkg/utils/tls.go @@ -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 +} diff --git a/pkg/whatsapp/whatsmeow/client.go b/pkg/whatsapp/whatsmeow/client.go index 9685622..12a223d 100644 --- a/pkg/whatsapp/whatsmeow/client.go +++ b/pkg/whatsapp/whatsmeow/client.go @@ -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 +} diff --git a/pkg/whatshooked/options.go b/pkg/whatshooked/options.go index a5a8634..564a083 100644 --- a/pkg/whatshooked/options.go +++ b/pkg/whatshooked/options.go @@ -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 + } +} diff --git a/pkg/whatshooked/server.go b/pkg/whatshooked/server.go index 6323a99..8b21ed4 100644 --- a/pkg/whatshooked/server.go +++ b/pkg/whatshooked/server.go @@ -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 }