diff --git a/.dockerignore b/.dockerignore index 8b30b24..f9beaeb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,6 @@ Dockerfile .git .gitignore README.md -*.example \ No newline at end of file +*.example +.env.example +.labels.example diff --git a/.env.example b/.env.example index e9cb250..5ee6229 100644 --- a/.env.example +++ b/.env.example @@ -10,11 +10,6 @@ VAPID_PRIVATE_KEY= # Example: mailto:admin@yourdomain.com or https://yourdomain.com/contact VAPID_SUBJECT=mailto:admin@virtonline.eu -# Flic Button Configuration -FLIC_BUTTON1_SERIAL=your_button1_serial -FLIC_BUTTON2_SERIAL=your_button2_serial -FLIC_BUTTON3_SERIAL=your_button3_serial - # Subscription Storage SUBSCRIPTIONS_FILE=subscriptions.json @@ -30,4 +25,19 @@ LOG_LEVEL=INFO # If you want to add a simple security layer between Flic and this app. # If set, configure Flic's HTTP request to include an "Authorization: Bearer YOUR_SECRET_VALUE" header. # use e.g.: openssl rand -hex 32 -FLIC_SECRET= \ No newline at end of file +FLIC_SECRET= + +# --- DNS and Network Configuration --- +# These settings help with Docker DNS resolution issues (EAI_AGAIN errors) + +# Maximum number of retry attempts for failed DNS resolutions +MAX_NOTIFICATION_RETRIES=3 + +# Initial delay in milliseconds before first retry (will increase with backoff) +INITIAL_RETRY_DELAY_MS=1000 + +# DNS resolution timeout in milliseconds +DNS_TIMEOUT_MS=5000 + +# HTTP request timeout in milliseconds +HTTP_TIMEOUT_MS=10000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 115d993..d4f67a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ myenv .vscode +.cursor # Node.js node_modules/ @@ -8,7 +9,6 @@ yarn-debug.log* yarn-error.log* .env subscriptions.json -labels # OS generated files .DS_Store diff --git a/README.md b/README.md index 4bdb3cc..416a2bb 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for ## Features * Receives POST requests on `/flic-webhook`. -* Parses `button_id` and `click_type` from the Flic request body. -* Looks up the target PWA push subscription based on `button_id` in a JSON file. +* Uses HTTP headers `Button-Name` and `Timestamp` from the Flic request. +* Parses `click_type` from the Flic request body. +* Looks up the target PWA push subscription based on the `Button-Name` header in a JSON file. * Sends a Web Push notification containing the click details (action, button, timestamp) to the corresponding PWA subscription. * Integrates with Traefik v3 via Docker labels. * Configurable via environment variables (`.env` file). @@ -27,10 +28,10 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for ## System Architecture ### Subscription Flow -![Subscription Flow](/diagrams/subscription-flow.png) +Subscription Flow ### Interaction Flow -![Interaction Flow](/diagrams/interaction-flow.png) +Interaction Flow ## Project Structure @@ -64,6 +65,10 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for * `ALLOWED_ORIGINS`: Comma-separated list of domains allowed by CORS. Include your PWA's domain if it needs to interact directly (e.g., for setup). Example: `https://my-pwa.com`. * `ALLOWED_METHODS`: (Default: `POST,OPTIONS`) Standard methods needed. * `ALLOWED_HEADERS`: (Default: `Content-Type,Authorization`) Standard headers needed. + * `MAX_NOTIFICATION_RETRIES`: (Default: `3`) Number of retry attempts for failed push notifications. Must be a number. + * `INITIAL_RETRY_DELAY_MS`: (Default: `1000`) Initial delay in milliseconds before first retry. Must be a number. + * `DNS_TIMEOUT_MS`: (Default: `5000`) DNS resolution timeout in milliseconds. Must be a number. + * `HTTP_TIMEOUT_MS`: (Default: `10000`) HTTP request timeout in milliseconds. Must be a number. * `TRAEFIK_SERVICE_HOST`: Your public domain for this service (e.g., `webpush.virtonline.eu`). * `TRAEFIK_CERT_RESOLVER`: The name of your TLS certificate resolver configured in Traefik (e.g., `le`, `myresolver`). @@ -134,25 +139,27 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for In your Flic app or Flic Hub SDK interface: 1. Select your Flic button. -2. Add an "Internet Request" action (or similar HTTP request action) for Single Click, Double Click, and/or Hold events. -3. **URL:** `https:///flic-webhook` (e.g., `https://webpush.virtonline.eu/flic-webhook`) -4. **Method:** `POST` -5. **Body Type:** `JSON` (or `application/json`) -6. **Body:** Configure the JSON body to include the button's serial number and the click type. Flic usually provides variables for these. The backend expects `button_id` and `click_type`. Adapt the keys if needed, or modify `server.js` to expect different keys (e.g., `serialNumber`). +2. Add an "Internet Request" action. +3. Fill in the following details: + * Set `POST` method. + * Set URL: `https://webpush.virtonline.eu/flic-webhook` + * Add headers: + * Key: `Authorization` + * Value: `Bearer ` (Replace `` with the actual secret from your `.env` file). + * **Body:** Configure the JSON body to include the click type. ```json { - "button_id": "{serialNumber}", - "click_type": "{clickType}", - "timestamp": "{timestamp}" + "click_type": "SingleClick" } ``` - *(Verify the exact variable names like `{serialNumber}`, `{clickType}`, `{timestamp}` within your specific Flic interface.)* -7. **Headers:** - * Add `Content-Type: application/json`. - * **(Optional - if `FLIC_SECRET` is set):** Add an `Authorization` header: - * Key: `Authorization` - * Value: `Bearer ` (Replace `` with the actual secret from your `.env` file). + * Set Content-Type: `application/json`. + * It should look like this: + + + * Tap on `Save action`. +4. Repeat for Double Click and/or Hold events. + ## API Endpoint * **`POST /flic-webhook`** @@ -161,16 +168,14 @@ In your Flic app or Flic Hub SDK interface: * **Request Body (JSON):** ```json { - "button_id": "SERIAL_NUMBER_OF_FLIC_BUTTON", - "click_type": "SingleClick | DoubleClick | Hold", - "timestamp": "ISO_8601_TIMESTAMP_STRING (Optional)" + "click_type": "SingleClick | DoubleClick | Hold" } ``` * **Responses:** * `200 OK`: Webhook received, push notification sent successfully. - * `400 Bad Request`: Missing `button_id` or `click_type` in the request body. + * `400 Bad Request`: Missing `Button-Name` header or `click_type` in the request body. * `401 Unauthorized`: Missing or invalid Bearer token (if `FLIC_SECRET` is enabled). - * `404 Not Found`: No subscription found in `subscriptions.json` for the given `button_id`. + * `404 Not Found`: No subscription found in `subscriptions.json` for the given `Button-Name`. * `410 Gone`: The push subscription associated with the button was rejected by the push service (likely expired or revoked). * `500 Internal Server Error`: Failed to send the push notification for other reasons. @@ -184,6 +189,35 @@ In your Flic app or Flic Hub SDK interface: } ``` +## Testing the Webhook + +Once your service is up and running, you can test the webhook endpoint using curl or any API testing tool: + +**Note:** In the command below, replace `a74181969a613c545d66c1e436e75c1e4a6` with your actual FLIC_SECRET value from your .env file. + +```bash +curl -X POST https://webpush.virtonline.eu/flic-webhook \ + -H "Authorization: Bearer a74181969a613c545d66c1e436e75c1e4a6" \ + -H "Content-Type: application/json" \ + -H "Button-Name: Game" \ + -H "Timestamp: 2025-03-26T01:10:20Z" \ + -d '{ + "click_type": "SingleClick" +}' +``` + +The expected response should be: +```json +{"message":"Push notification sent successfully"} +``` + +If successful, the above response indicates that: +1. Your webhook endpoint is properly configured +2. The button ID was found in your subscriptions.json file +3. The web push notification was successfully sent to the registered PUSH API endpoint (e.g. https://jmt17.google.com/fcm/send/cf907M...) + +If you receive a different response, refer to the Troubleshooting section below. + ## Troubleshooting * **Check Backend Logs:** `docker logs flic-webhook-webpush`. Look for errors related to configuration, file access, JSON parsing, authentication, or sending push notifications. diff --git a/images/flic-button-request.png b/images/flic-button-request.png new file mode 100644 index 0000000..b7ff5ce Binary files /dev/null and b/images/flic-button-request.png differ diff --git a/diagrams/interaction-flow.mmd b/images/interaction-flow.mmd similarity index 100% rename from diagrams/interaction-flow.mmd rename to images/interaction-flow.mmd diff --git a/diagrams/interaction-flow.png b/images/interaction-flow.png similarity index 100% rename from diagrams/interaction-flow.png rename to images/interaction-flow.png diff --git a/diagrams/subscription-flow.mmd b/images/subscription-flow.mmd similarity index 100% rename from diagrams/subscription-flow.mmd rename to images/subscription-flow.mmd diff --git a/diagrams/subscription-flow.png b/images/subscription-flow.png similarity index 100% rename from diagrams/subscription-flow.png rename to images/subscription-flow.png diff --git a/labels b/labels new file mode 100644 index 0000000..5451eca --- /dev/null +++ b/labels @@ -0,0 +1,32 @@ +# Enable Traefik for this container +traefik.enable=true + +# Docker Network +traefik.docker.network=traefik + +# Route requests based on Host +traefik.http.routers.flic-webhook-webpush.rule=Host(`webpush.virtonline.eu`) +# Specify the entrypoint ('websecure' for HTTPS) +traefik.http.routers.flic-webhook-webpush.entrypoints=web-secure +traefik.http.routers.flic-webhook-webpush.tls=true # Enable TLS +traefik.http.routers.flic-webhook-webpush.tls.certResolver=default +# Link the router to the service defined below +traefik.http.routers.flic-webhook-webpush.service=flic-webhook-webpush + +# Point the service to the container's port +traefik.http.services.flic-webhook-webpush.loadbalancer.server.port=3000 + +# Middleware CORS +traefik.http.middlewares.cors-headers.headers.accesscontrolallowmethods=POST,GET,OPTIONS # Allow POST, GET, and OPTIONS requests +traefik.http.middlewares.cors-headers.headers.accesscontrolalloworiginlist=https://game-timer.virtonline.eu # Allow requests from game-timer.virtonline.eu +traefik.http.middlewares.cors-headers.headers.accesscontrolallowheaders=Content-Type,Authorization # Allow Content-Type and Authorization headers +traefik.http.middlewares.cors-headers.headers.accesscontrolmaxage=600 # Cache preflight responses for 10 minutes +traefik.http.middlewares.cors-headers.headers.addvaryheader=true # Add Vary header to responses +# Apply the middleware to the router +traefik.http.routers.flic-webhook-webpush.middlewares=cors-headers + +# Middleware Rate Limiting +traefik.http.middlewares.flic-ratelimit.ratelimit.average=10 # requests per second +traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20 +# Apply the middleware to the router +traefik.http.routers.flic-webhook-webpush.middlewares=flic-ratelimit diff --git a/labels.example b/labels.example deleted file mode 100644 index 88fce42..0000000 --- a/labels.example +++ /dev/null @@ -1,29 +0,0 @@ -# Traefik v3 Labels for flic-webhook-webpush service - -# Enable Traefik for this container -traefik.enable=true - -# --- HTTP Router Definition --- -# Define an HTTP router named 'flic-webhook-http' -# Route requests based on Host and PathPrefix -traefik.http.routers.flic-webhook.rule=Host(`webpush.virtonline.eu`) -# Specify the entrypoint (e.g., 'websecure' for HTTPS) -traefik.http.routers.flic-webhook.entrypoints=websecure -# Specify the TLS certificate resolver -traefik.http.routers.flic-webhook.tls.certresolver=default -# Link this router to the service defined below -traefik.http.routers.flic-webhook.service=flic-webhook - -# --- HTTP Service Definition --- -# Define an HTTP service named 'flic-webhook' -# Point the service to the container's port (default 3000) -traefik.http.services.flic-webhook.loadbalancer.server.port=3000 - -# --- Middleware (Optional Example: Rate Limiting - Uncomment to enable) --- -# traefik.http.middlewares.flic-ratelimit.ratelimit.average=10 # requests per second -# traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20 -# traefik.http.routers.flic-webhook.middlewares=flic-ratelimit - -# --- Docker Network --- -# Ensure Traefik uses the correct network to communicate with the container -traefik.docker.network=traefik \ No newline at end of file diff --git a/server.js b/server.js index 2973f47..36942bd 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,7 @@ const webpush = require('web-push'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); +const dns = require('dns'); // Add DNS module // Load environment variables from .env file require('dotenv').config(); @@ -19,7 +20,26 @@ const flicSecret = process.env.FLIC_SECRET; // Optional Bearer token secret for const allowedOrigins = (process.env.ALLOWED_ORIGINS || "").split(',').map(origin => origin.trim()).filter(origin => origin); const allowedMethods = (process.env.ALLOWED_METHODS || "POST,OPTIONS,GET").split(',').map(method => method.trim()).filter(method => method); const allowedHeaders = (process.env.ALLOWED_HEADERS || "Content-Type,Authorization").split(',').map(header => header.trim()).filter(header => header); +// Retry configuration for DNS resolution issues +const maxRetries = parseInt(process.env.MAX_NOTIFICATION_RETRIES || 3, 10); +const initialRetryDelay = parseInt(process.env.INITIAL_RETRY_DELAY_MS || 1000, 10); // 1 second +// HTTP request timeout configuration +const httpTimeout = parseInt(process.env.HTTP_TIMEOUT_MS || 10000, 10); // 10 seconds +// Configure global HTTP agent with timeouts to prevent hanging requests +const https = require('https'); +const http = require('http'); + +// Custom HTTPS agent with timeout +const httpsAgent = new https.Agent({ + keepAlive: true, + timeout: httpTimeout, + maxSockets: 50, // Limit concurrent connections +}); + +// Apply the agent to the webpush module if possible +// Note: The web-push library might use its own agent, but this is a precaution +https.globalAgent = httpsAgent; // --- Validation --- if (!vapidPublicKey || !vapidPrivateKey || !vapidSubject) { @@ -34,6 +54,43 @@ webpush.setVapidDetails( vapidPrivateKey ); +// Configure DNS settings for more reliable resolution in containerized environments +// These settings can help with temporary DNS resolution failures +dns.setDefaultResultOrder('ipv4first'); // Prefer IPv4 to avoid some IPv6 issues in containers +const dnsTimeout = parseInt(process.env.DNS_TIMEOUT_MS || 5000, 10); +dns.setServers(dns.getServers()); // Reset DNS servers (can help in some Docker environments) + +// You can optionally configure a specific DNS server if needed: +// Example: dns.setServers(['8.8.8.8', '1.1.1.1']); + +// --- Utility function for retrying web push notifications with exponential backoff --- +async function sendWebPushWithRetry(subscription, payload, retryCount = 0, delay = initialRetryDelay) { + try { + return await webpush.sendNotification(subscription, payload); + } catch (error) { + // Check if the error is a DNS resolution error that might be temporary + const isDnsError = error.code === 'EAI_AGAIN' || + error.code === 'ENOTFOUND' || + error.code === 'ETIMEDOUT'; + + if (isDnsError && retryCount < maxRetries) { + console.log(`DNS resolution failed (${error.code}). Retrying notification in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})...`); + + // Wait for the delay + await new Promise(resolve => setTimeout(resolve, delay)); + + // Exponential backoff with jitter + const nextDelay = delay * (1.5 + Math.random() * 0.5); + + // Retry recursively with increased count and delay + return sendWebPushWithRetry(subscription, payload, retryCount + 1, nextDelay); + } + + // If we've exhausted retries or it's not a DNS error, rethrow + throw error; + } +} + // --- Subscription Loading and Management --- let subscriptions = {}; // In-memory cache of subscriptions @@ -159,51 +216,54 @@ app.post('/subscribe', async (req, res) => { // --- Flic Webhook Endpoint --- // Apply Flic-specific authentication ONLY to this route app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => { - // Assuming Flic sends 'button_id' which is the serial number - const { button_id, click_type, timestamp } = req.body; + // Get buttonName from Header 'Button-Name' and timestamp from Header 'Timestamp' + const buttonName = req.headers['button-name']; + const timestamp = req.headers['timestamp']; + // Still get click_type from the request body + const { click_type } = req.body; - console.log(`Received webhook: Button=${button_id}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`); + console.log(`Received webhook: Button=${buttonName}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`); // Basic validation - if (!button_id || !click_type) { - console.warn(`Webhook Error: Missing button_id or click_type`); - return res.status(400).json({ message: 'Bad Request: Missing button_id or click_type' }); + if (!buttonName || !click_type) { + console.warn(`Webhook Error: Missing Button-Name header or click_type in body`); + return res.status(400).json({ message: 'Bad Request: Missing Button-Name header or click_type in request body' }); } - const normalizedButtonId = button_id.toLowerCase(); // Use lowercase for lookup consistency + const normalizedButtonName = buttonName.toLowerCase(); // Use lowercase for lookup consistency - // Find the subscription associated with this button ID - const subscription = subscriptions[normalizedButtonId]; + // Find the subscription associated with this normalized button name + const subscription = subscriptions[normalizedButtonName]; if (!subscription) { - console.warn(`Webhook: No subscription found for button ID: ${normalizedButtonId} (original: ${button_id})`); - return res.status(404).json({ message: `Not Found: No subscription configured for button ${normalizedButtonId}` }); + console.warn(`Webhook: No subscription found for button ID: ${normalizedButtonName} (original: ${buttonName})`); + return res.status(404).json({ message: `Not Found: No subscription configured for button ${normalizedButtonName}` }); } // --- Send Web Push Notification --- const payload = JSON.stringify({ title: 'Flic Button Action', - body: `Button ${click_type}`, // Simplified body + body: `Button ${normalizedButtonName}: ${click_type}`, // Simplified body data: { action: click_type, - button: normalizedButtonId, // Send normalized ID + button: normalizedButtonName, // Send normalized button name timestamp: timestamp || new Date().toISOString() } // icon: '/path/to/icon.png' }); try { - console.log(`Sending push notification for ${normalizedButtonId} to endpoint: ${subscription.endpoint.substring(0, 40)}...`); - await webpush.sendNotification(subscription, payload); - console.log(`Push notification sent successfully for button ${normalizedButtonId}.`); + console.log(`Sending push notification for ${normalizedButtonName} to endpoint: ${subscription.endpoint.substring(0, 40)}...`); + await sendWebPushWithRetry(subscription, payload); + console.log(`Push notification sent successfully for button ${normalizedButtonName}.`); res.status(200).json({ message: 'Push notification sent successfully' }); } catch (error) { - console.error(`Error sending push notification for button ${normalizedButtonId}:`, error.body || error.message || error); + console.error(`Error sending push notification for button ${normalizedButtonName}:`, error.body || error.message || error); if (error.statusCode === 404 || error.statusCode === 410) { - console.warn(`Subscription for button ${normalizedButtonId} is invalid or expired (404/410). Removing it.`); + console.warn(`Subscription for button ${normalizedButtonName} is invalid or expired (404/410). Removing it.`); // Optionally remove the stale subscription - delete subscriptions[normalizedButtonId]; + delete subscriptions[normalizedButtonName]; saveSubscriptions(); // Attempt to save the updated list res.status(410).json({ message: 'Subscription Gone' }); } else { @@ -223,7 +283,6 @@ app.get('/health', (req, res) => { // --- Start Server --- // Use http.createServer to allow graceful shutdown -const http = require('http'); const server = http.createServer(app); server.listen(port, () => { @@ -234,6 +293,9 @@ server.listen(port, () => { console.log(`Flic Webhook Auth: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`); console.log(`Subscription Endpoint Auth: Disabled`); console.log(`Subscriptions File: ${subscriptionsFilePath}`); + console.log(`Push Notification Retry Config: ${maxRetries} retries, ${initialRetryDelay}ms initial delay`); + console.log(`DNS Config: IPv4 first, timeout ${dnsTimeout}ms`); + console.log(`HTTP Timeout: ${httpTimeout}ms`); }); // --- Graceful Shutdown --- @@ -249,7 +311,7 @@ const closeGracefully = (signal) => { setTimeout(() => { console.error('Could not close connections in time, forcefully shutting down'); process.exit(1); - }, 10000); // 10 seconds timeout + }, 10000); // 10 seconds timeout - This is a literal so no need to parse } process.on('SIGTERM', () => closeGracefully('SIGTERM'));