let traefik handle CORS

This commit is contained in:
cpu
2025-03-31 13:17:52 +02:00
parent 81e11c155e
commit 2120e965fb
6 changed files with 45 additions and 129 deletions

View File

@@ -20,14 +20,6 @@ DEFAULT_BUTTON_NAME=game-button
BASIC_AUTH_USERNAME=user12345
BASIC_AUTH_PASSWORD=password
# --- CORS Configuration (Optional but Recommended) ---
# Comma-separated list of allowed origins for requests (e.g., your PWA frontend URL)
# If blank or not set, CORS might block browser requests (like from a setup page).
# Use '*' carefully, preferably list specific domains.
ALLOWED_ORIGINS=https://game-timer.virtonline.eu,http://localhost
ALLOWED_METHODS=POST,GET
ALLOWED_HEADERS=Content-Type,Authorization,button-name,button-battery-level,timestamp
# --- Web Push Retry Configuration (Optional) ---
# Number of retries on failure (e.g., DNS issues)
NOTIFICATION_MAX_RETRIES=3

View File

@@ -15,7 +15,6 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
* Integrates with Traefik v3 via Docker labels.
* Configurable via environment variables (`.env` file).
* Optional Basic Authentication for securing the `/webhook` and `/subscribe` endpoints.
* CORS configuration for allowing requests (needed if your PWA management interface interacts with the `/subscribe` endpoint).
## Prerequisites
@@ -61,21 +60,16 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
* `VAPID_PRIVATE_KEY`: The private key generated in step 2. **Keep this secret!**
* `VAPID_SUBJECT`: A `mailto:` or `https:` URL identifying you or your application (e.g., `mailto:admin@yourdomain.com`). Used by push services to contact you.
* `PORT`: (Default: `3000`) The internal port the Node.js app listens on. Traefik will map to this.
* `SUBSCRIPTIONS_FILE`: (Default: `/app/subscriptions.json`) The path *inside the container* where the button-to-subscription mapping is stored.
* `SUBSCRIPTIONS_FILE`: (Default: `subscriptions.json`) The path *inside the container* where the button-to-subscription mapping is stored.
* `DEFAULT_BUTTON_NAME`: (Default: `game-button`) The default button name to use when the `Button-Name` header is not provided in the webhook request.
* `BASIC_AUTH_USERNAME`: (Optional) Username for Basic Authentication. If set along with `BASIC_AUTH_PASSWORD`, authentication will be enabled for `/webhook` and `/subscribe`.
* `BASIC_AUTH_PASSWORD`: (Optional) Password for Basic Authentication. If set along with `BASIC_AUTH_USERNAME`, authentication will be enabled.
* `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.
* `NOTIFICATION_MAX_RETRIES`: (Default: `3`) Number of retry attempts for failed push notifications. Must be a number.
* `NOTIFICATION_FIRST_RETRY_DELAY_MS`: (Default: `10`) Delay in milliseconds for the first retry attempt. Setting to 0-10ms provides near-immediate first retry for transient DNS issues. Must be a number.
* `NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS`: (Default: `1000`) Base delay in milliseconds for subsequent retries. Each additional retry uses this value with exponential backoff and jitter. 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.
* `LOG_LEVEL`: (Default: `info`) Controls verbosity of logs. Valid values are `error`, `warn`, `info`, or `debug`. Use `debug` to see detailed header information and other diagnostic messages.
* `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`).
4. **Configure Traefik Labels:**
* Copy the example `labels` file:
@@ -83,24 +77,6 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
cp labels.example labels
```
5. **Prepare Subscription Mapping File:**
* Edit the `subscriptions.json` file
* Add entries mapping your Flic button's serial number (as a lowercase string key) to the PWA `PushSubscription` object.
```json
{
"game": { // <-- Replace with your actual Flic Button name (lowercase recommended)
"endpoint": "https://your_pwa_push_endpoint...",
"expirationTime": null,
"keys": {
"p256dh": "YOUR_PWA_SUBSCRIPTION_P256DH_KEY",
"auth": "YOUR_PWA_SUBSCRIPTION_AUTH_KEY"
}
}
// Add more entries for other buttons if needed
}
```
* Ensure this file contains valid JSON.
## Running the Service
1. **Build the Docker Image:**
@@ -113,7 +89,7 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
This command runs the container in detached mode (`-d`), names it, connects it to the `traefik` network, passes environment variables from the `.env` file, applies the Traefik labels from the `labels` file, and mounts the `subscriptions.json` file into the container.
```bash
docker run -d --name flic-webhook-webpush \
docker run -rm -d --name flic-webhook-webpush \
--network traefik \
--env-file .env \
--label-file labels \
@@ -137,14 +113,18 @@ In your Flic app or Flic Hub SDK interface:
1. Select your Flic button.
2. Add an "Internet Request" action.
3. Fill in the following details:
* Set `GET` method.
* Select the `GET` method.
* Set URL with query parameter: `https://<your_domain>/webhook/SingleClick` (Replace `<your_domain>` with your actual service domain, e.g., `webpush.virtonline.eu`).
* **If Basic Authentication is enabled:**
* Set the `Username` and `Password` fields to the values from your `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables.
* **If Basic Authentication is disabled:**
* Leave the `Username` and `Password` fields empty.
* Tap on `Save action`.
4. Repeat for Double Click and/or Hold events, changing the `click_type` parameter accordingly (e.g., `/DoubleClick`).
* Set the Headers:
* Set the `Key` fields to `Authorization`.
* Set the `Value` fields to `Basic <base64 encoded username:password>`.
* Click `ADD`.
* Tap on `SAVE ACTION`.
4. Repeat for Double Click (i.e., `/DoubleClick`) and Hold (i.e., `/Hold`) events.
The request for the Hold event should look like this:
<img src="images/flic-button-request.png" width="300" alt="Flic Button Request">
## API Endpoints
@@ -184,13 +164,12 @@ In your Flic app or Flic Hub SDK interface:
* `Timestamp`: Timestamp of the button event (sent by the Flic system).
* `Button-Battery-Level`: The battery level percentage of the button (sent by the Flic system).
* **Push Notification Payload (`data` field):** The service sends a JSON payload within the push notification. The client-side Service Worker can access this data via `event.data.json()`. The structure is:
```json
{
"action": "SingleClick", // or DoubleClick, Hold
"button": "game-button", // Normalized button name (lowercase)
"timestamp": "2024-03-28T15:00:00.000Z", // ISO 8601 timestamp or current server time
"batteryLevel": 100 // Integer percentage (0-100) or 'N/A' if not provided
}
```bash
curl -X GET https://webpush.virtonline.eu/webhook/SingleClick \
-H 'Authorization: Basic cGxheWVyOlNldmVuT2ZOaW5l' \
-H "Button-Name: Game-button" \
-H "Timestamp: 2025-03-26T01:10:20Z" \
-H "Button-Battery-Level: 100"
```
* **Responses:**
* `200 OK`: Webhook received, push notification sent successfully.
@@ -204,25 +183,14 @@ In your Flic app or Flic Hub SDK interface:
Once your service is up and running, you can test the webhook endpoint using curl or any API testing tool. This example assumes Basic Authentication is enabled.
**Note:** Replace `<username>`, `<password>`, `<your_domain>`, and `<button_name>` with your actual values. The `Button-Name` header is optional and will default to the value of `DEFAULT_BUTTON_NAME` if not provided.
**Note:** Replace `<username>`, `<password>` with your actual values. The `Button-Name` header is optional and will default to the value of `DEFAULT_BUTTON_NAME` if not provided.
```bash
# Generate Base64 credentials (run this once)
# echo -n '<username>:<password>' | base64
# Example using generated Base64 string (replace YOUR_BASE64_CREDS)
curl -X GET "https://<your_domain>/webhook/SingleClick" \
-H "Authorization: Basic YOUR_BASE64_CREDS" \
-H "Button-Name: <button_name>" \
curl -X GET "https://webpush.virtonline.eu/webhook/SingleClick" \
-H "Authorization: Basic $(echo -n 'user:password' | base64)" \
-H "Button-Name: game-button" \
-H "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-H "Button-Battery-Level: 100" \
# Example using curl's built-in Basic Auth (-u)
curl -X GET "https://<your_domain>/webhook/SingleClick" \
-u "<username>:<password>" \
-H "Button-Name: <button_name>" \
-H "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-H "Button-Battery-Level: 100" \
-H "Button-Battery-Level: 100"
```
The expected response should be:
@@ -232,7 +200,7 @@ The expected response should be:
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
2. The button name 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 102 KiB

21
package-lock.json generated
View File

@@ -9,7 +9,6 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"web-push": "^3.6.7"
@@ -151,18 +150,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -614,14 +601,6 @@
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

View File

@@ -17,7 +17,6 @@
"author": "Your Name",
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"web-push": "^3.6.7"

View File

@@ -1,6 +1,5 @@
const express = require('express');
const webpush = require('web-push');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const dns = require('dns'); // Add DNS module
@@ -20,9 +19,6 @@ const basicAuthUsername = process.env.BASIC_AUTH_USERNAME;
const basicAuthPassword = process.env.BASIC_AUTH_PASSWORD;
// Note: We are NOT adding specific authentication for the /subscribe endpoint in this version.
// Consider adding API key or other auth if exposing this publicly.
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.NOTIFICATION_MAX_RETRIES || 3, 10);
const subsequentRetryDelay = parseInt(process.env.NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS || 1000, 10); // 1 second base delay for subsequent retries
@@ -182,34 +178,18 @@ loadSubscriptions();
// --- Express App Setup ---
const app = express();
// --- CORS Middleware ---
const corsOptions = {
origin: (origin, callback) => {
if (!origin || allowedOrigins.length === 0 || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
console.warn(`CORS: Blocked origin: ${origin}`);
callback(new Error('Not allowed by CORS'));
}
},
methods: allowedMethods,
allowedHeaders: allowedHeaders,
optionsSuccessStatus: 204 // For pre-flight requests
};
app.use(cors(corsOptions));
// Enable pre-flight requests for all relevant routes
app.options('/webhook', cors(corsOptions));
app.options('/subscribe', cors(corsOptions));
// --- Body Parsing Middleware ---
app.use(express.json());
// --- Basic Authentication Middleware ---
const authenticateBasic = (req, res, next) => {
// Skip authentication for OPTIONS requests (CORS preflight)
// Skip authentication for OPTIONS requests (CORS preflight - Traefik might still forward them or handle them)
// It's safe to keep this check.
if (req.method === 'OPTIONS') {
return next();
logger.debug('Auth: Skipping auth for OPTIONS request.');
// Traefik should ideally respond to OPTIONS, but if it forwards, we just proceed without auth check.
// We don't need to send CORS headers here anymore.
return res.sendStatus(204); // Standard practice for preflight response if it reaches the backend
}
// Skip authentication if username or password are not set in environment
@@ -249,7 +229,7 @@ const authenticateBasic = (req, res, next) => {
app.post('/subscribe', authenticateBasic, async (req, res) => {
let { button_id, subscription } = req.body;
logger.debug('All headers received:');
logger.debug('All headers received on /subscribe:');
Object.keys(req.headers).forEach(headerName => {
logger.debug(` ${headerName}: ${req.headers[headerName]}`);
});
@@ -299,7 +279,7 @@ app.get('/webhook/:click_type', authenticateBasic, async (req, res) => {
const batteryLevel = batteryLevelHeader ? parseInt(batteryLevelHeader, 10) || batteryLevelHeader : 'N/A';
// Log all headers received from Flic
logger.debug('All headers received:');
logger.debug('All headers received on /webhook:');
Object.keys(req.headers).forEach(headerName => {
logger.debug(` ${headerName}: ${req.headers[headerName]}`);
});
@@ -362,9 +342,7 @@ const server = http.createServer(app);
server.listen(port, () => {
logger.info(`Flic Webhook to WebPush server listening on port ${port}`);
logger.info(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`);
logger.info(`Allowed Methods: ${allowedMethods.join(', ')}`);
logger.info(`Allowed Headers: ${allowedHeaders.join(', ')}`);
logger.info('CORS: Handled by Traefik (expected)');
// Log Basic Auth status instead of Flic Secret
if (basicAuthUsername && basicAuthPassword) {
logger.info('Authentication: Basic Auth Enabled');