Compare commits

..

1 Commits

Author SHA1 Message Date
cpu
52b19701cb first version 2025-03-31 23:27:57 +02:00
11 changed files with 341 additions and 228 deletions

View File

@@ -6,5 +6,11 @@ Dockerfile
.gitignore .gitignore
README.md README.md
*.example *.example
.env.example images
.labels.example .env
env
labels
subscriptions.json

39
.env Normal file
View File

@@ -0,0 +1,39 @@
# Flic to PWA WebPush Configuration
# --- VAPID Keys (Required) ---
# Generate using: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E
VAPID_PRIVATE_KEY=ErEgsDKYQi5j2KPERC_gCtrEALAD0k-dWSwrrcD0-JU
VAPID_SUBJECT=mailto:admin@virtonline.eu
# --- Server Configuration ---
# Internal port for the Node.js app
PORT=3000
SUBSCRIPTIONS_FILE=subscriptions.json
DEFAULT_BUTTON_NAME=game-button
# --- Authentication (Optional) ---
# If both USERNAME and PASSWORD are set, Basic Auth will be enabled for:
# - POST /subscribe
# - GET /webhook
# Leave blank to disable authentication.
BASIC_AUTH_USERNAME=player
BASIC_AUTH_PASSWORD=SevenOfNine
# --- Web Push Retry Configuration (Optional) ---
# Number of retries on failure (e.g., DNS issues)
NOTIFICATION_MAX_RETRIES=3
# First retry delay in milliseconds (minimal delay for immediate retry)
NOTIFICATION_FIRST_RETRY_DELAY_MS=10
# Base delay in milliseconds for subsequent retries (used for exponential backoff)
NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS=1000
# --- Network Configuration (Optional) ---
# Timeout for DNS lookups (ms)
DNS_TIMEOUT_MS=5000
# Timeout for outgoing HTTP requests (ms)
HTTP_TIMEOUT_MS=10000
# --- Logging ---
# Controls log verbosity: error, warn, info, debug
LOG_LEVEL=info

View File

@@ -1,43 +1,39 @@
# --- Application Configuration --- # Flic to PWA WebPush Configuration
# --- VAPID Keys (REQUIRED for Web Push) --- # --- VAPID Keys (Required) ---
# Generate these once using npx web-push generate-vapid-keys (or other tools) # Generate using: npx web-push generate-vapid-keys
# Keep the private key SECRET!
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:mailto:user@example.org
# Subject claim for VAPID. Use a 'mailto:' URI or an 'https:' URL identifying your application. # --- Server Configuration ---
# Example: mailto:admin@yourdomain.com or https://yourdomain.com/contact # Internal port for the Node.js app
VAPID_SUBJECT=mailto:admin@virtonline.eu PORT=3000
SUBSCRIPTIONS_FILE=/app/subscriptions.json
DEFAULT_BUTTON_NAME=game-button
# Subscription Storage # --- Authentication (Optional) ---
SUBSCRIPTIONS_FILE=subscriptions.json # If both USERNAME and PASSWORD are set, Basic Auth will be enabled for:
# - POST /subscribe
# - GET /webhook
# Leave blank to disable authentication.
BASIC_AUTH_USERNAME=user12345
BASIC_AUTH_PASSWORD=password
# CORS # --- Web Push Retry Configuration (Optional) ---
ALLOWED_ORIGINS=https://game-timer.virtonline.eu # Number of retries on failure (e.g., DNS issues)
ALLOWED_METHODS=POST,OPTIONS,GET NOTIFICATION_MAX_RETRIES=3
ALLOWED_HEADERS=Content-Type,Authorization # First retry delay in milliseconds (minimal delay for immediate retry)
NOTIFICATION_FIRST_RETRY_DELAY_MS=10
# Base delay in milliseconds for subsequent retries (used for exponential backoff)
NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS=1000
# Logging Configuration # --- Network Configuration (Optional) ---
LOG_LEVEL=INFO # Timeout for DNS lookups (ms)
# --- Security (Optional) ---
# 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=
# --- 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 DNS_TIMEOUT_MS=5000
# Timeout for outgoing HTTP requests (ms)
# HTTP request timeout in milliseconds
HTTP_TIMEOUT_MS=10000 HTTP_TIMEOUT_MS=10000
# --- Logging ---
# Controls log verbosity: error, warn, info, debug
LOG_LEVEL=info

4
.gitignore vendored
View File

@@ -1,6 +1,7 @@
myenv myenv
.vscode .vscode
.cursor .cursor
subscriptions.json
# Node.js # Node.js
node_modules/ node_modules/
@@ -8,8 +9,7 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.env .env
subscriptions.json env
# OS generated files # OS generated files
.DS_Store .DS_Store
.DS_Store? .DS_Store?

191
README.md
View File

@@ -6,15 +6,15 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
## Features ## Features
* Receives GET requests on `/flic-webhook`. * Receives GET requests on `/webhook`.
* Receives POST requests on `/subscribe` to manage button-PWA mappings.
* Uses HTTP headers `Button-Name` and `Timestamp` from the Flic request. * Uses HTTP headers `Button-Name` and `Timestamp` from the Flic request.
* Gets `click_type` from query parameter. * Gets `click_type` from URL path.
* Looks up the target PWA push subscription based on the `Button-Name` header in a JSON file. * 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. * Sends a Web Push notification containing the click details (action, button, timestamp) to the corresponding PWA subscription.
* Integrates with Traefik v3 via Docker labels. * Integrates with Traefik v3 via Docker labels.
* Configurable via environment variables (`.env` file). * Configurable via environment variables (`.env` file).
* Optional bearer token authentication for securing the Flic webhook endpoint. * Optional Basic Authentication for securing the `/webhook` and `/subscribe` endpoints.
* CORS configuration for allowing requests (needed if your PWA management interface interacts with this service, although not strictly necessary for the Flic->Backend->PWA push flow itself).
## Prerequisites ## Prerequisites
@@ -51,55 +51,31 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
This will output a Public Key and a Private Key. This will output a Public Key and a Private Key.
3. **Configure Environment Variables:** 3. **Configure Environment Variables:**
* Copy the example `.env` file: * Copy the example `.env` file:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
* Edit the `.env` file with your specific values: * Edit the `.env` file with your specific values:
* `VAPID_PUBLIC_KEY`: The public key generated in step 2. **Your PWA will also need this key** when it subscribes to push notifications. * `VAPID_PUBLIC_KEY`: The public key generated in step 2. **Your PWA will also need this key** when it subscribes to push notifications.
* `VAPID_PRIVATE_KEY`: The private key generated in step 2. **Keep this secret!** * `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. * `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. * `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.
* `FLIC_SECRET`: (Optional) Set a strong, random secret string if you want to secure the webhook endpoint using Bearer token authentication. Generate with `openssl rand -hex 32` or a password manager. * `DEFAULT_BUTTON_NAME`: (Default: `game-button`) The default button name to use when the `Button-Name` header is not provided in the webhook request.
* `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`. * `BASIC_AUTH_USERNAME`: (Optional) Username for Basic Authentication. If set along with `BASIC_AUTH_PASSWORD`, authentication will be enabled for `/webhook` and `/subscribe`.
* `ALLOWED_METHODS`: (Default: `POST,OPTIONS`) Standard methods needed. * `BASIC_AUTH_PASSWORD`: (Optional) Password for Basic Authentication. If set along with `BASIC_AUTH_USERNAME`, authentication will be enabled.
* `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.
* `MAX_NOTIFICATION_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.
* `INITIAL_RETRY_DELAY_MS`: (Default: `1000`) Initial delay in milliseconds before first retry. 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. * `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. * `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. * `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:** 4. **Configure Traefik Labels:**
* Copy the example `labels` file: * Copy the example `labels` file:
```bash ```bash
cp labels.example labels cp labels.example labels
``` ```
* **Important:** Edit the `labels` file. Replace `${TRAEFIK_SERVICE_HOST}`, `${TRAEFIK_CERT_RESOLVER}`, and `${PORT}` with the *actual values* from your `.env` file, as `docker run` does not substitute variables in label files.
* Example replacement: `Host(\`${TRAEFIK_SERVICE_HOST}\`)` becomes `Host(`webpush.virtonline.eu`)`.
* `traefik.http.routers.flic-webhook.tls.certresolver=${TRAEFIK_CERT_RESOLVER}` becomes `traefik.http.routers.flic-webhook.tls.certresolver=myresolver`.
* `traefik.http.services.flic-webhook.loadbalancer.server.port=${PORT}` becomes `traefik.http.services.flic-webhook.loadbalancer.server.port=3000`.
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 ## Running the Service
@@ -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. 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 ```bash
docker run -d --name flic-webhook-webpush \ docker run --rm -d --name flic-webhook-webpush \
--network traefik \ --network traefik \
--env-file .env \ --env-file .env \
--label-file labels \ --label-file labels \
@@ -137,44 +113,99 @@ In your Flic app or Flic Hub SDK interface:
1. Select your Flic button. 1. Select your Flic button.
2. Add an "Internet Request" action. 2. Add an "Internet Request" action.
3. Fill in the following details: 3. Fill in the following details:
* Set `GET` method. * Select the `GET` method.
* Set URL with query parameter: `https://webpush.virtonline.eu/flic-webhook?click_type=SingleClick` * Set URL with query parameter: `https://webpush.virtonline.eu/webhook/SingleClick`
* Add headers: * **If Basic Authentication is enabled:**
* Key: `Authorization` * Set the Headers:
* Value: `Bearer <FLIC_SECRET>` (Replace `<FLIC_SECRET>` with the actual secret from your `.env` file). * Set the `Key` fields to `Authorization`.
* It should look like this: * Set the `Value` fields to `Basic <base64 encoded username:password>` (e.g., `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`). Use `$(echo -n 'user:password' | base64)` to generate the base64 encoded string.
* 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 Configuration"> // TODO: Update image --> <img src="images/flic-button-request.png" width="300" alt="Flic Button Request">
* Tap on `Save action`. ## App Example: "HTTP Shortcuts" by waboodoo
4. Repeat for Double Click and/or Hold events.
## API Endpoint Search the Play Store - there might be others with similar names.
* **`GET /flic-webhook`** 1. Install the App: Download and install "HTTP Shortcuts" or a similar app from the Google Play Store.
2. Create a New Shortcut within the App:
* Open the app and usually tap a '+' or 'Add' button.
* Give your shortcut a Name (e.g., "Turn on Office Light", "Log Water Intake").
* Choose an Icon.
* Enter the URL you want the request sent to (your webhook URL, IFTTT URL, Home Assistant webhook trigger, etc.).
* Select the HTTP Method (GET, POST, PUT, DELETE, etc. - often GET or POST for simple triggers).
* For POST/PUT: You'll likely need to configure the Request Body (e.g., JSON data) and Content Type (e.g., application/json).
* Authentication: Configure Basic Auth, Bearer Tokens, or custom Headers if your endpoint requires authentication.
* Other Options: Explore settings for response handling (show message on success/failure), timeouts, etc.
* Save the shortcut configuration within the app.
3. Add the Widget/Shortcut to your Home Screen:
* Go to your Android Home Screen.
* Long-press on an empty space.
* Select "Widgets".
* Scroll through the list to find the "HTTP Shortcuts" app (or the app you installed).
* Drag the app's widget or shortcut option onto your home screen.
* The app will likely ask you to choose which specific shortcut (the one you just created) this widget should trigger. Select it.
4. Test: Tap the newly created button on your home screen. It should trigger the internet request you configured.
## API Endpoints
* **`POST /subscribe`**
* **Description:** Adds or updates the Web Push subscription associated with a specific Flic button ID.
* **Authentication:** Optional Basic Authentication via `Authorization` header if `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` are configured.
* **Request Body:** JSON object containing:
* `button_id` (string, optional): The unique identifier for the Flic button (lowercase recommended, e.g., "game-button", "lights-button"). If not provided, the value of `DEFAULT_BUTTON_NAME` environment variable will be used as a fallback.
* `subscription` (object, required): The [PushSubscription object](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription) obtained from the browser's Push API.
```json
{
"button_id": "game-button",
"subscription": {
"endpoint": "https://your_pwa_push_endpoint...",
"expirationTime": null,
"keys": {
"p256dh": "YOUR_PWA_SUBSCRIPTION_P256DH_KEY",
"auth": "YOUR_PWA_SUBSCRIPTION_AUTH_KEY"
}
}
}
```
* **Responses:**
* `201 Created`: Subscription saved successfully.
* `400 Bad Request`: Missing or invalid `subscription` object in the request body.
* `401 Unauthorized`: Missing or invalid Basic Authentication credentials (if authentication is enabled).
* `500 Internal Server Error`: Failed to save the subscription to the file.
* **`GET /webhook/:click_type`**
* **Description:** Receives Flic button events. * **Description:** Receives Flic button events.
* **Authentication:** Optional Bearer token via `Authorization` header if `FLIC_SECRET` is configured. * **Authentication:** Optional Basic Authentication via `Authorization` header if `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` are configured.
* **Query Parameters:** * **URL Parameters:**
* `click_type`: The type of button press (SingleClick, DoubleClick, or Hold) * `click_type` (required): The type of button press (e.g., `SingleClick`, `DoubleClick`, or `Hold`).
* **Optional Headers:**
* `Button-Name`: The identifier of the Flic button (sent by the Flic system). If not provided, the value of `DEFAULT_BUTTON_NAME` environment variable will be used as a fallback.
* `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).
* **Responses:** * **Responses:**
* `200 OK`: Webhook received, push notification sent successfully. * `200 OK`: Webhook received, push notification sent successfully.
* `400 Bad Request`: Missing `Button-Name` header or `click_type` query parameter. * `400 Bad Request`: Missing `Button-Name` header or `click_type` URL parameter.
* `401 Unauthorized`: Missing or invalid Bearer token (if `FLIC_SECRET` is enabled). * `401 Unauthorized`: Missing or invalid Basic Authentication credentials (if authentication is enabled).
* `404 Not Found`: No subscription found in `subscriptions.json` for the given `Button-Name`. * `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). * `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. * `500 Internal Server Error`: Failed to send the push notification for other reasons.
## Testing the Webhook ## Testing the Webhook
Once your service is up and running, you can test the webhook endpoint using curl or any API testing tool: 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:** In the command below, replace `a74181969a613c545d66c1e436e75c1e4a6` with your actual FLIC_SECRET value from your .env file. **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 ```bash
curl -X GET "https://webpush.virtonline.eu/flic-webhook?click_type=SingleClick" \ curl -X GET "https://webpush.virtonline.eu/webhook/SingleClick" \
-H "Authorization: Bearer a74181969a613c545d66c1e436e75c1e4a6" \ -H "Authorization: Basic $(echo -n 'user:password' | base64)" \
-H "Button-Name: Game" \ -H "Button-Name: game-button" \
-H "Timestamp: 2025-03-26T01:10:20Z" -H "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-H "Button-Battery-Level: 100"
``` ```
The expected response should be: The expected response should be:
@@ -184,7 +215,7 @@ The expected response should be:
If successful, the above response indicates that: If successful, the above response indicates that:
1. Your webhook endpoint is properly configured 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...) 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. If you receive a different response, refer to the Troubleshooting section below.
@@ -192,10 +223,16 @@ If you receive a different response, refer to the Troubleshooting section below.
## Troubleshooting ## Troubleshooting
* **Check Backend Logs:** `docker logs flic-webhook-webpush`. Look for errors related to configuration, file access, JSON parsing, authentication, or sending push notifications. * **Check Backend Logs:** `docker logs flic-webhook-webpush`. Look for errors related to configuration, file access, JSON parsing, authentication, or sending push notifications.
* To see detailed debug information including all headers received from the Flic button, set `LOG_LEVEL=debug` in your .env file. * To see detailed debug information including all headers received from the Flic button, set `LOG_LEVEL=debug` in your .env file.
* **Check Traefik Logs:** `docker logs traefik`. Look for routing errors or certificate issues. * **Check Traefik Logs:** `docker logs traefik`. Look for routing errors or certificate issues.
* **Verify `.env`:** Ensure all required variables are set correctly, especially VAPID keys and Traefik settings. * **Verify `.env`:** Ensure all required variables are set correctly, especially VAPID keys and Traefik settings.
* **Verify `labels`:** Double-check that variables were correctly substituted manually and match your `.env` and Traefik setup. * **Verify `labels`:** Double-check that variables were correctly substituted manually and match your `.env` and Traefik setup.
* **Verify `subscriptions.json`:** Ensure it's valid JSON and the button serial number (key) matches exactly what Flic sends (check backend logs for "Received webhook: Button=..."). Check if the subscription details are correct. Case sensitivity matters for the JSON keys (button serials). * **Verify `subscriptions.json`:** Ensure it's valid JSON and the button serial number (key) matches exactly what Flic sends (check backend logs for "Received webhook: Button=..."). Check if the subscription details are correct. Case sensitivity matters for the JSON keys (button serials).
* **Check Flic Configuration:** Ensure the URL, Method, Body, and Headers (especially `Content-Type` and `Authorization` if used) are correct in the Flic action setup. Use `curl` or Postman to test the endpoint manually first. * **Check Flic Configuration:** Ensure the URL, Method, `click_type` parameter, and authentication details (Username/Password if enabled) are correct in the Flic action setup. Use `curl` or Postman to test the endpoint manually first.
* **PWA Service Worker:** Remember that the PWA needs a correctly registered Service Worker to receive and handle the incoming push messages. Ensure the PWA subscribes using the *same* `VAPID_PUBLIC_KEY` configured in the backend's `.env`. * **PWA Service Worker:** Remember that the PWA needs a correctly registered Service Worker to receive and handle the incoming push messages. Ensure the PWA subscribes using the *same* `VAPID_PUBLIC_KEY` configured in the backend's `.env`.
* **Push Notification Retry Mechanism:** The service includes an optimized retry mechanism for handling temporary DNS resolution issues:
* First retry happens immediately or with minimal delay (controlled by `NOTIFICATION_FIRST_RETRY_DELAY_MS`, default 10ms)
* Subsequent retries use exponential backoff with jitter (starting from `NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS`, default 1000ms)
* Maximum number of retries is controlled by `NOTIFICATION_MAX_RETRIES` (default 3)
* This approach minimizes latency for transient DNS issues while preventing excessive requests for persistent problems
* Adjust these values in your `.env` file based on your network conditions and reliability requirements

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 378 KiB

View File

@@ -17,16 +17,16 @@ traefik.http.routers.flic-webhook-webpush.service=flic-webhook-webpush
traefik.http.services.flic-webhook-webpush.loadbalancer.server.port=3000 traefik.http.services.flic-webhook-webpush.loadbalancer.server.port=3000
# Middleware CORS # Middleware CORS
traefik.http.middlewares.cors-headers.headers.accesscontrolallowmethods=POST,GET,OPTIONS traefik.http.middlewares.cors-headers.headers.accesscontrolallowmethods=POST,GET
traefik.http.middlewares.cors-headers.headers.accesscontrolalloworiginlist=https://game-timer.virtonline.eu traefik.http.middlewares.cors-headers.headers.accesscontrolalloworiginlist=https://game-timer.virtonline.eu
traefik.http.middlewares.cors-headers.headers.accesscontrolallowheaders=Content-Type,Authorization traefik.http.middlewares.cors-headers.headers.accesscontrolallowheaders=Content-Type,Authorization,button-name,button-battery-level,timestamp
traefik.http.middlewares.cors-headers.headers.accesscontrolallowcredentials=true
traefik.http.middlewares.cors-headers.headers.accesscontrolmaxage=600 traefik.http.middlewares.cors-headers.headers.accesscontrolmaxage=600
traefik.http.middlewares.cors-headers.headers.addvaryheader=true traefik.http.middlewares.cors-headers.headers.addvaryheader=true
# Apply the middleware to the router
traefik.http.routers.flic-webhook-webpush.middlewares=cors-headers
# Middleware Rate Limiting # Middleware Rate Limiting
traefik.http.middlewares.flic-ratelimit.ratelimit.average=10 traefik.http.middlewares.flic-ratelimit.ratelimit.average=10
traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20 traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20
# Apply the middleware to the router
traefik.http.routers.flic-webhook-webpush.middlewares=flic-ratelimit # Apply both middlewares to the router (comma-separated list)
traefik.http.routers.flic-webhook-webpush.middlewares=cors-headers,flic-ratelimit

21
package-lock.json generated
View File

@@ -9,7 +9,6 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"web-push": "^3.6.7" "web-push": "^3.6.7"
@@ -151,18 +150,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" "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": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -614,14 +601,6 @@
"node": ">= 0.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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

View File

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

171
server.js
View File

@@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const webpush = require('web-push'); const webpush = require('web-push');
const cors = require('cors');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const dns = require('dns'); // Add DNS module const dns = require('dns'); // Add DNS module
@@ -13,16 +12,17 @@ const port = process.env.PORT || 3000;
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY; const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY; const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
const vapidSubject = process.env.VAPID_SUBJECT; // mailto: or https: const vapidSubject = process.env.VAPID_SUBJECT; // mailto: or https:
const subscriptionsFilePath = process.env.SUBSCRIPTIONS_FILE || path.join(__dirname, 'subscriptions.json'); const subscriptionsFilePath = process.env.SUBSCRIPTIONS_FILE || path.join(__dirname, '/app/subscriptions.json');
const flicSecret = process.env.FLIC_SECRET; // Optional Bearer token secret for Flic webhook const defaultButtonName = process.env.DEFAULT_BUTTON_NAME || 'game-button';
// Basic Authentication Credentials
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. // 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. // 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 // Retry configuration for DNS resolution issues
const maxRetries = parseInt(process.env.MAX_NOTIFICATION_RETRIES || 3, 10); const maxRetries = parseInt(process.env.NOTIFICATION_MAX_RETRIES || 3, 10);
const initialRetryDelay = parseInt(process.env.INITIAL_RETRY_DELAY_MS || 1000, 10); // 1 second const subsequentRetryDelay = parseInt(process.env.NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS || 1000, 10); // 1 second base delay for subsequent retries
const firstRetryDelay = parseInt(process.env.NOTIFICATION_FIRST_RETRY_DELAY_MS || 10, 10); // 10 milliseconds - minimal delay for first retry
// HTTP request timeout configuration // HTTP request timeout configuration
const httpTimeout = parseInt(process.env.HTTP_TIMEOUT_MS || 10000, 10); // 10 seconds const httpTimeout = parseInt(process.env.HTTP_TIMEOUT_MS || 10000, 10); // 10 seconds
// Logging level configuration // Logging level configuration
@@ -95,7 +95,7 @@ dns.setServers(dns.getServers()); // Reset DNS servers (can help in some Docker
// Example: dns.setServers(['8.8.8.8', '1.1.1.1']); // Example: dns.setServers(['8.8.8.8', '1.1.1.1']);
// --- Utility function for retrying web push notifications with exponential backoff --- // --- Utility function for retrying web push notifications with exponential backoff ---
async function sendWebPushWithRetry(subscription, payload, retryCount = 0, delay = initialRetryDelay) { async function sendWebPushWithRetry(subscription, payload, retryCount = 0, delay = subsequentRetryDelay) {
try { try {
return await webpush.sendNotification(subscription, payload); return await webpush.sendNotification(subscription, payload);
} catch (error) { } catch (error) {
@@ -105,13 +105,25 @@ async function sendWebPushWithRetry(subscription, payload, retryCount = 0, delay
error.code === 'ETIMEDOUT'; error.code === 'ETIMEDOUT';
if (isDnsError && retryCount < maxRetries) { if (isDnsError && retryCount < maxRetries) {
console.log(`DNS resolution failed (${error.code}). Retrying notification in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})...`); // For first retry (retryCount = 0), use minimal delay or no delay
const actualDelay = retryCount === 0 ? firstRetryDelay : delay;
// Wait for the delay if (retryCount === 0) {
await new Promise(resolve => setTimeout(resolve, delay)); logger.info(`DNS resolution failed (${error.code}). Retrying notification immediately or with minimal delay of ${firstRetryDelay}ms (attempt ${retryCount + 1}/${maxRetries})...`);
} else {
logger.info(`DNS resolution failed (${error.code}). Retrying notification in ${actualDelay}ms (attempt ${retryCount + 1}/${maxRetries})...`);
}
// Exponential backoff with jitter // Wait for the delay (minimal or none for first retry)
const nextDelay = delay * (1.5 + Math.random() * 0.5); if (actualDelay > 0) {
await new Promise(resolve => setTimeout(resolve, actualDelay));
}
// Calculate next delay with exponential backoff + jitter
// First retry uses subsequentRetryDelay, subsequent retries use exponential increase
const nextDelay = retryCount === 0 ?
subsequentRetryDelay :
delay * (1.5 + Math.random() * 0.5);
// Retry recursively with increased count and delay // Retry recursively with increased count and delay
return sendWebPushWithRetry(subscription, payload, retryCount + 1, nextDelay); return sendWebPushWithRetry(subscription, payload, retryCount + 1, nextDelay);
@@ -166,67 +178,71 @@ loadSubscriptions();
// --- Express App Setup --- // --- Express App Setup ---
const app = express(); 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('/flic-webhook', cors(corsOptions));
app.options('/subscribe', cors(corsOptions));
// --- Body Parsing Middleware --- // --- Body Parsing Middleware ---
app.use(express.json()); app.use(express.json());
// --- Authentication Middleware (For Flic Webhook Only) --- // --- Basic Authentication Middleware ---
const authenticateFlicRequest = (req, res, next) => { const authenticateBasic = (req, res, next) => {
// Only apply auth if flicSecret is configured // Skip authentication for OPTIONS requests (CORS preflight - Traefik might still forward them or handle them)
if (!flicSecret) { // It's safe to keep this check.
if (req.method === 'OPTIONS') {
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
if (!basicAuthUsername || !basicAuthPassword) {
logger.warn('Auth: Basic Auth username or password not configured. Skipping authentication.');
return next(); return next();
} }
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn('Auth (Flic): Missing or malformed Authorization header'); if (!authHeader || !authHeader.startsWith('Basic ')) {
return res.status(401).json({ message: 'Unauthorized: Missing or malformed Bearer token' }); logger.warn('Auth: Missing or malformed Basic Authorization header');
res.setHeader('WWW-Authenticate', 'Basic realm="Restricted Area"');
return res.status(401).json({ message: 'Unauthorized: Basic Authentication required' });
} }
const token = authHeader.split(' ')[1]; const credentials = authHeader.split(' ')[1];
if (token !== flicSecret) { const decodedCredentials = Buffer.from(credentials, 'base64').toString('utf8');
logger.warn('Auth (Flic): Invalid Bearer token received'); const [username, password] = decodedCredentials.split(':');
return res.status(401).json({ message: 'Unauthorized: Invalid token' });
}
logger.debug('Auth (Flic): Request authenticated successfully.'); // Note: Use constant-time comparison for production environments if possible,
next(); // but for this scope, direct comparison is acceptable.
if (username === basicAuthUsername && password === basicAuthPassword) {
logger.debug('Auth: Basic Authentication successful.');
return next();
} else {
logger.warn('Auth: Invalid Basic Authentication credentials received.');
res.setHeader('WWW-Authenticate', 'Basic realm="Restricted Area"');
return res.status(401).json({ message: 'Unauthorized: Invalid credentials' });
}
}; };
app.post('/subscribe', async (req, res) => { // --- Routes ---
const { button_id, subscription } = req.body;
logger.debug('All headers received:'); // Subscribe endpoint: Add a new button->subscription mapping
// Apply Basic Authentication
app.post('/subscribe', authenticateBasic, async (req, res) => {
let { button_id, subscription } = req.body;
logger.debug('All headers received on /subscribe:');
Object.keys(req.headers).forEach(headerName => { Object.keys(req.headers).forEach(headerName => {
logger.debug(` ${headerName}: ${req.headers[headerName]}`); logger.debug(` ${headerName}: ${req.headers[headerName]}`);
}); });
// If button_id is not provided, use defaultButtonName from environment variable
if (!button_id || typeof button_id !== 'string' || button_id.trim() === '') {
button_id = defaultButtonName;
logger.info(`No button_id provided, using default button name: ${button_id}`);
}
logger.info(`Received subscription request for button: ${button_id}`); logger.info(`Received subscription request for button: ${button_id}`);
// Basic Validation // Basic Validation - now we only validate subscription since button_id will use default if not provided
if (!button_id || typeof button_id !== 'string' || button_id.trim() === '') {
logger.warn('Subscription Error: Missing or invalid button_id');
return res.status(400).json({ message: 'Bad Request: Missing or invalid button_id' });
}
if (!subscription || typeof subscription !== 'object' || !subscription.endpoint || !subscription.keys || !subscription.keys.p256dh || !subscription.keys.auth) { if (!subscription || typeof subscription !== 'object' || !subscription.endpoint || !subscription.keys || !subscription.keys.p256dh || !subscription.keys.auth) {
logger.warn('Subscription Error: Missing or invalid subscription object structure'); logger.warn('Subscription Error: Missing or invalid subscription object structure');
return res.status(400).json({ message: 'Bad Request: Missing or invalid subscription object' }); return res.status(400).json({ message: 'Bad Request: Missing or invalid subscription object' });
@@ -250,26 +266,30 @@ app.post('/subscribe', async (req, res) => {
}); });
// --- Flic Webhook Endpoint (GET only) --- // --- Flic Webhook Endpoint (GET only) ---
// Apply Flic-specific authentication to this route // Apply Basic Authentication
app.get('/flic-webhook', authenticateFlicRequest, async (req, res) => { app.get('/webhook/:click_type', authenticateBasic, async (req, res) => {
// Get buttonName from Header 'Button-Name' and timestamp from Header 'Timestamp' // Get buttonName from Header 'Button-Name' and timestamp from Header 'Timestamp'
const buttonName = req.headers['button-name']; const buttonName = req.headers['button-name'] || defaultButtonName;
const timestamp = req.headers['timestamp']; const timestamp = req.headers['timestamp'];
// Get click_type from query parameter instead of request body // Get click_type from URL path
const click_type = req.query.click_type; const click_type = req.params.click_type;
// Get battery level from Header 'Button-Battery-Level'
const batteryLevelHeader = req.headers['button-battery-level'];
// Use 'N/A' if header is missing or empty, otherwise parse as integer (or keep as string if parsing fails)
const batteryLevel = batteryLevelHeader ? parseInt(batteryLevelHeader, 10) || batteryLevelHeader : 'N/A';
// Log all headers received from Flic // Log all headers received from Flic
logger.debug('All headers received:'); logger.debug('All headers received on /webhook:');
Object.keys(req.headers).forEach(headerName => { Object.keys(req.headers).forEach(headerName => {
logger.debug(` ${headerName}: ${req.headers[headerName]}`); logger.debug(` ${headerName}: ${req.headers[headerName]}`);
}); });
logger.info(`Received GET webhook: Button=${buttonName}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`); logger.info(`Received GET webhook: Button=${buttonName}, Type=${click_type}, Battery=${batteryLevel}%, Timestamp=${timestamp || 'N/A'}`);
// Basic validation // Basic validation
if (!buttonName || !click_type) { if (!click_type) {
logger.warn(`Webhook Error: Missing Button-Name header or click_type query parameter`); logger.warn(`Webhook Error: Missing click_type query parameter`);
return res.status(400).json({ message: 'Bad Request: Missing Button-Name header or click_type query parameter' }); return res.status(400).json({ message: 'Bad Request: Missing click_type query parameter' });
} }
const normalizedButtonName = buttonName.toLowerCase(); // Use lowercase for lookup consistency const normalizedButtonName = buttonName.toLowerCase(); // Use lowercase for lookup consistency
@@ -289,7 +309,8 @@ app.get('/flic-webhook', authenticateFlicRequest, async (req, res) => {
data: { data: {
action: click_type, action: click_type,
button: normalizedButtonName, // Send normalized button name button: normalizedButtonName, // Send normalized button name
timestamp: timestamp || new Date().toISOString() timestamp: timestamp || new Date().toISOString(),
batteryLevel: batteryLevel // Use the extracted value
} }
// icon: '/path/to/icon.png' // icon: '/path/to/icon.png'
}); });
@@ -321,13 +342,17 @@ const server = http.createServer(app);
server.listen(port, () => { server.listen(port, () => {
logger.info(`Flic Webhook to WebPush server listening on port ${port}`); logger.info(`Flic Webhook to WebPush server listening on port ${port}`);
logger.info(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`); logger.info('CORS: Handled by Traefik');
logger.info(`Allowed Methods: ${allowedMethods.join(', ')}`); // Log Basic Auth status instead of Flic Secret
logger.info(`Allowed Headers: ${allowedHeaders.join(', ')}`); if (basicAuthUsername && basicAuthPassword) {
logger.info(`Flic Webhook Auth: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`); logger.info('Authentication: Basic Auth Enabled');
logger.info(`Subscription Endpoint Auth: Disabled`); } else {
logger.info('Authentication: Basic Auth Disabled (username/password not set)');
}
logger.info(`Subscription Endpoint Auth: ${basicAuthUsername && basicAuthPassword ? 'Enabled (Basic)' : 'Disabled'}`);
logger.info(`Webhook Endpoint Auth: ${basicAuthUsername && basicAuthPassword ? 'Enabled (Basic)' : 'Disabled'}`);
logger.info(`Subscriptions File: ${subscriptionsFilePath}`); logger.info(`Subscriptions File: ${subscriptionsFilePath}`);
logger.info(`Push Notification Retry Config: ${maxRetries} retries, ${initialRetryDelay}ms initial delay`); logger.info(`Push Notification Retry Config: ${maxRetries} retries, first retry: ${firstRetryDelay}ms, subsequent retries: ${subsequentRetryDelay}ms base delay`);
logger.info(`DNS Config: IPv4 first, timeout ${dnsTimeout}ms`); logger.info(`DNS Config: IPv4 first, timeout ${dnsTimeout}ms`);
logger.info(`HTTP Timeout: ${httpTimeout}ms`); logger.info(`HTTP Timeout: ${httpTimeout}ms`);
logger.info(`Log Level: ${LOG_LEVEL.toUpperCase()}`); logger.info(`Log Level: ${LOG_LEVEL.toUpperCase()}`);

View File

@@ -0,0 +1,32 @@
[Unit]
Description=flic-webhook-webpush (virt-flic-webhook-webpush)
Requires=docker.service
After=docker.service
DefaultDependencies=no
[Service]
Type=simple
Environment="HOME=/root"
ExecStartPre=-/usr/bin/env sh -c '/usr/bin/env docker kill virt-flic-webhook-webpush 2>/dev/null || true'
ExecStartPre=-/usr/bin/env sh -c '/usr/bin/env docker rm virt-flic-webhook-webpush 2>/dev/null || true'
ExecStartPre=/usr/bin/env sh -c 'touch /virt/flic-webhook-webpush/subscriptions.json'
ExecStart=/usr/bin/env docker run \
--rm \
--name=virt-flic-webhook-webpush \
--log-driver=none \
--network=traefik \
--env-file=/virt/flic-webhook-webpush/env \
--label-file=/virt/flic-webhook-webpush/labels \
--mount type=bind,src=/virt/flic-webhook-webpush/subscriptions.json,dst=/app/subscriptions.json \
flic-webhook-webpush
ExecStop=-/usr/bin/env sh -c '/usr/bin/env docker kill virt-flic-webhook-webpush 2>/dev/null || true'
ExecStop=-/usr/bin/env sh -c '/usr/bin/env docker rm virt-flic-webhook-webpush 2>/dev/null || true'
Restart=always
RestartSec=30
SyslogIdentifier=virt-flic-webhook-webpush
[Install]
WantedBy=multi-user.target