Updated qr code events and tls server
This commit is contained in:
@@ -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
|
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
|
1. Open WhatsApp
|
||||||
2. Go to Settings > Linked Devices
|
2. Go to Settings > Linked Devices
|
||||||
3. Tap "Link a Device"
|
3. Tap "Link a Device"
|
||||||
4. Scan the QR code from the terminal
|
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
|
### Alternative: Use CLI Tool
|
||||||
|
|
||||||
You can also use the CLI tool outside Docker to link accounts, then mount the session:
|
You can also use the CLI tool outside Docker to link accounts, then mount the session:
|
||||||
|
|||||||
216
README.md
216
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
|
- **Multi-Account Support**: Connect to multiple WhatsApp accounts simultaneously
|
||||||
- **Dual Client Types**: Support for both personal WhatsApp (whatsmeow) and WhatsApp Business API
|
- **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
|
- **Webhook Integration**: Register multiple webhooks to receive WhatsApp messages
|
||||||
- **Two-Way Communication**: Webhooks can respond with messages to send back to WhatsApp
|
- **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
|
- **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
|
- **CLI Management**: Command-line tool for managing accounts and hooks
|
||||||
- **Structured Logging**: JSON-based logging with configurable log levels
|
- **Structured Logging**: JSON-based logging with configurable log levels
|
||||||
- **Authentication**: HTTP Basic Auth and API key authentication for server endpoints
|
- **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
|
- **Event Logging**: Optional event persistence to file, SQLite, or PostgreSQL
|
||||||
- **Library Mode**: Use WhatsHooked as a Go library in your own applications
|
- **Library Mode**: Use WhatsHooked as a Go library in your own applications
|
||||||
- **Flexible Handlers**: Mount individual HTTP handlers in custom servers
|
- **Flexible Handlers**: Mount individual HTTP handlers in custom servers
|
||||||
@@ -65,6 +67,46 @@ make build
|
|||||||
./bin/whatshook-server -config config.json
|
./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
|
## Architecture
|
||||||
|
|
||||||
The project uses an event-driven architecture with the following packages:
|
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
|
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:
|
Edit the configuration file to add your WhatsApp accounts and webhooks:
|
||||||
|
|
||||||
```json
|
```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)
|
- `events`: List of event types to subscribe to (optional, defaults to all)
|
||||||
- `description`: Optional description
|
- `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
|
### Server Authentication
|
||||||
|
|
||||||
The server supports two authentication methods to protect API endpoints:
|
The server supports two authentication methods to protect API endpoints:
|
||||||
|
|||||||
4
TODO.md
4
TODO.md
@@ -9,5 +9,5 @@
|
|||||||
- [ ] Optional Postgres server,database for event saving and hook registration
|
- [ ] Optional Postgres server,database for event saving and hook registration
|
||||||
- [✔️] Optional Event logging into directory for each type
|
- [✔️] 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)
|
- [ ] 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
|
- [✔️] 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.
|
- [✔️] HTTPS Server with certbot support, self signed certificate generation or custom certificate paths.
|
||||||
@@ -5,7 +5,18 @@
|
|||||||
"default_country_code": "27",
|
"default_country_code": "27",
|
||||||
"username": "",
|
"username": "",
|
||||||
"password": "",
|
"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": [
|
"whatsapp": [
|
||||||
{
|
{
|
||||||
39
config/config.https-custom.example.json
Normal file
39
config/config.https-custom.example.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
41
config/config.https-letsencrypt.example.json
Normal file
41
config/config.https-letsencrypt.example.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
38
config/config.https-self-signed.example.json
Normal file
38
config/config.https-self-signed.example.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
2
go.mod
2
go.mod
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32
|
go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32
|
||||||
|
golang.org/x/crypto v0.46.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,7 +37,6 @@ require (
|
|||||||
go.mau.fi/libsignal v0.2.1 // indirect
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
go.mau.fi/util v0.9.4 // indirect
|
go.mau.fi/util v0.9.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.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/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
|||||||
@@ -24,6 +24,24 @@ type ServerConfig struct {
|
|||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
AuthKey string `json:"auth_key,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
|
// 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
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ func WhatsAppPairFailedEvent(ctx context.Context, accountID string, err error) E
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WhatsAppQRCodeEvent creates a WhatsApp QR code event
|
// 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{
|
return NewEvent(ctx, EventWhatsAppQRCode, map[string]any{
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
"qr_code": qrCode,
|
"qr_code": qrCode,
|
||||||
|
"qr_url": qrURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
pkg/handlers/qr.go
Normal file
57
pkg/handlers/qr.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package logging
|
package logging
|
||||||
|
|
||||||
import "log/slog"
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
// Logger interface allows users to plug in their own logger
|
// Logger interface allows users to plug in their own logger
|
||||||
type Logger interface {
|
type Logger interface {
|
||||||
@@ -33,7 +36,7 @@ func Init(level string) {
|
|||||||
slogLevel = slog.LevelInfo
|
slogLevel = slog.LevelInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := slog.NewTextHandler(nil, &slog.HandlerOptions{
|
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
Level: slogLevel,
|
Level: slogLevel,
|
||||||
})
|
})
|
||||||
defaultLogger = &slogLogger{logger: slog.New(handler)}
|
defaultLogger = &slogLogger{logger: slog.New(handler)}
|
||||||
|
|||||||
207
pkg/utils/tls.go
Normal file
207
pkg/utils/tls.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
package whatsmeow
|
package whatsmeow
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||||
@@ -22,6 +26,7 @@ import (
|
|||||||
waEvents "go.mau.fi/whatsmeow/types/events"
|
waEvents "go.mau.fi/whatsmeow/types/events"
|
||||||
waLog "go.mau.fi/whatsmeow/util/log"
|
waLog "go.mau.fi/whatsmeow/util/log"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
|
"rsc.io/qr"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
@@ -37,6 +42,8 @@ type Client struct {
|
|||||||
mediaConfig config.MediaConfig
|
mediaConfig config.MediaConfig
|
||||||
showQR bool
|
showQR bool
|
||||||
keepAliveCancel context.CancelFunc
|
keepAliveCancel context.CancelFunc
|
||||||
|
qrCode string
|
||||||
|
qrCodeMutex sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new whatsmeow client
|
// NewClient creates a new whatsmeow client
|
||||||
@@ -111,17 +118,28 @@ func (c *Client) Connect(ctx context.Context) error {
|
|||||||
case "code":
|
case "code":
|
||||||
logging.Info("QR code received for pairing", "account_id", c.id)
|
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
|
// Display QR code in terminal
|
||||||
fmt.Println("\n========================================")
|
fmt.Println("\n========================================")
|
||||||
fmt.Printf("WhatsApp QR Code for account: %s\n", c.id)
|
fmt.Printf("WhatsApp QR Code for account: %s\n", c.id)
|
||||||
fmt.Printf("Phone: %s\n", c.phoneNumber)
|
fmt.Printf("Phone: %s\n", c.phoneNumber)
|
||||||
fmt.Println("========================================")
|
fmt.Println("========================================")
|
||||||
fmt.Println("Scan this QR code with WhatsApp on your phone:")
|
fmt.Println("Scan this QR code with WhatsApp on your phone:")
|
||||||
|
fmt.Println()
|
||||||
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
|
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Or open in browser: %s\n", qrURL)
|
||||||
fmt.Println("========================================")
|
fmt.Println("========================================")
|
||||||
|
|
||||||
// Publish QR code event
|
// Publish QR code event with URL
|
||||||
c.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, c.id, evt.Code))
|
c.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, c.id, evt.Code, qrURL))
|
||||||
|
|
||||||
case "success":
|
case "success":
|
||||||
logging.Info("Pairing successful", "account_id", c.id, "phone", c.phoneNumber)
|
logging.Info("Pairing successful", "account_id", c.id, "phone", c.phoneNumber)
|
||||||
@@ -675,3 +693,59 @@ func getExtensionFromMimeType(mimeType string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,3 +131,48 @@ func WithSQLiteDatabase(sqlitePath string) Option {
|
|||||||
c.Database.SQLitePath = sqlitePath
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,15 +2,18 @@ package whatshooked
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
|
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/utils"
|
"git.warky.dev/wdevs/whatshooked/pkg/utils"
|
||||||
"go.mau.fi/whatsmeow/types"
|
"go.mau.fi/whatsmeow/types"
|
||||||
|
"golang.org/x/crypto/acme/autocert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server is the optional built-in HTTP server
|
// 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 {
|
func (s *Server) Start() error {
|
||||||
// Subscribe to hook success events for two-way communication
|
// Subscribe to hook success events for two-way communication
|
||||||
s.wh.EventBus().Subscribe(events.EventHookSuccess, s.handleHookResponse)
|
s.wh.EventBus().Subscribe(events.EventHookSuccess, s.handleHookResponse)
|
||||||
@@ -40,20 +43,25 @@ func (s *Server) Start() error {
|
|||||||
Handler: mux,
|
Handler: mux,
|
||||||
}
|
}
|
||||||
|
|
||||||
logging.Info("Starting HTTP server",
|
// Connect to WhatsApp accounts after server starts
|
||||||
"host", s.wh.config.Server.Host,
|
|
||||||
"port", s.wh.config.Server.Port,
|
|
||||||
"address", addr)
|
|
||||||
|
|
||||||
// Connect to WhatsApp accounts
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(100 * time.Millisecond) // Give HTTP server a moment to start
|
time.Sleep(100 * time.Millisecond) // Give server a moment to start
|
||||||
logging.Info("HTTP server ready, connecting to WhatsApp accounts")
|
logging.Info("Server ready, connecting to WhatsApp accounts")
|
||||||
if err := s.wh.ConnectAll(context.Background()); err != nil {
|
if err := s.wh.ConnectAll(context.Background()); err != nil {
|
||||||
logging.Error("Failed to connect to WhatsApp accounts", "error", err)
|
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)
|
// Start server (blocking)
|
||||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
return err
|
return err
|
||||||
@@ -62,6 +70,127 @@ func (s *Server) Start() error {
|
|||||||
return nil
|
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
|
// Stop stops the HTTP server gracefully
|
||||||
func (s *Server) Stop(ctx context.Context) error {
|
func (s *Server) Stop(ctx context.Context) error {
|
||||||
if s.httpServer != nil {
|
if s.httpServer != nil {
|
||||||
@@ -96,6 +225,9 @@ func (s *Server) setupRoutes() *http.ServeMux {
|
|||||||
// Serve media files (with auth)
|
// Serve media files (with auth)
|
||||||
mux.HandleFunc("/api/media/", h.ServeMedia)
|
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)
|
// Business API webhooks (no auth - Meta validates via verify_token)
|
||||||
mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook)
|
mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook)
|
||||||
|
|
||||||
@@ -103,7 +235,8 @@ func (s *Server) setupRoutes() *http.ServeMux {
|
|||||||
"health", "/health",
|
"health", "/health",
|
||||||
"hooks", "/api/hooks",
|
"hooks", "/api/hooks",
|
||||||
"accounts", "/api/accounts",
|
"accounts", "/api/accounts",
|
||||||
"send", "/api/send")
|
"send", "/api/send",
|
||||||
|
"qr", "/api/qr")
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user