diff --git a/.dockerignore b/.dockerignore index 8b30b24..06749c9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,12 @@ Dockerfile .git .gitignore README.md -*.example \ No newline at end of file +*.example +images +.env +env +labels +subscriptions.json + + + diff --git a/.env.example b/.env.example index e9cb250..7fb422f 100644 --- a/.env.example +++ b/.env.example @@ -1,33 +1,39 @@ -# --- Application Configuration --- +# Flic to PWA WebPush Configuration -# --- VAPID Keys (REQUIRED for Web Push) --- -# Generate these once using npx web-push generate-vapid-keys (or other tools) -# Keep the private key SECRET! +# --- VAPID Keys (Required) --- +# Generate using: npx web-push generate-vapid-keys VAPID_PUBLIC_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. -# Example: mailto:admin@yourdomain.com or https://yourdomain.com/contact -VAPID_SUBJECT=mailto:admin@virtonline.eu +# --- Server Configuration --- +# Internal port for the Node.js app +PORT=3000 +SUBSCRIPTIONS_FILE=/app/subscriptions.json +DEFAULT_BUTTON_NAME=game-button -# Flic Button Configuration -FLIC_BUTTON1_SERIAL=your_button1_serial -FLIC_BUTTON2_SERIAL=your_button2_serial -FLIC_BUTTON3_SERIAL=your_button3_serial +# --- 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=user12345 +BASIC_AUTH_PASSWORD=password -# Subscription Storage -SUBSCRIPTIONS_FILE=subscriptions.json +# --- 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 -# CORS -ALLOWED_ORIGINS=https://game-timer.virtonline.eu -ALLOWED_METHODS=POST,OPTIONS,GET -ALLOWED_HEADERS=Content-Type,Authorization +# --- Network Configuration (Optional) --- +# Timeout for DNS lookups (ms) +DNS_TIMEOUT_MS=5000 +# Timeout for outgoing HTTP requests (ms) +HTTP_TIMEOUT_MS=10000 -# Logging Configuration -LOG_LEVEL=INFO - -# --- 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= \ No newline at end of file +# --- Logging --- +# Controls log verbosity: error, warn, info, debug +LOG_LEVEL=info \ No newline at end of file diff --git a/.gitignore b/.gitignore index 115d993..7df275b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ myenv .vscode +.cursor +subscriptions.json # Node.js node_modules/ @@ -7,9 +9,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .env -subscriptions.json -labels - +env # OS generated files .DS_Store .DS_Store? diff --git a/README.md b/README.md index a9f4b2a..0e60fcb 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,15 @@ 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. +* 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. +* Gets `click_type` from URL path. +* 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). -* Optional bearer token authentication for securing the Flic webhook endpoint. -* 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). +* Optional Basic Authentication for securing the `/webhook` and `/subscribe` endpoints. ## Prerequisites @@ -24,6 +25,14 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for * **Node.js & npm/npx (Optional):** Needed only locally to generate VAPID keys easily. Not required for running the container. * **PWA Push Subscription Details:** You need to obtain the Push Subscription object (containing `endpoint`, `keys.p256dh`, `keys.auth`) from your PWA after the user grants notification permission. +## System Architecture + +### Subscription Flow +Subscription Flow + +### Interaction Flow +Interaction Flow + ## Project Structure ## Setup @@ -41,23 +50,7 @@ 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. -3. **Obtain PWA Push Subscription Details:** - * Your PWA needs to use the Push API to request notification permission from the user. - * When permission is granted, the browser's push service provides a `PushSubscription` object. - * This object typically looks like: - ```json - { - "endpoint": "https://updates.push.services.mozilla.com/...", - "expirationTime": null, - "keys": { - "p256dh": "...", - "auth": "..." - } - } - ``` - * You need to get this JSON object from your PWA (e.g., display it to the user to copy, send it to a setup endpoint - though that's more complex). - -4. **Configure Environment Variables:** +3. **Configure Environment Variables:** * Copy the example `.env` file: ```bash cp .env.example .env @@ -67,41 +60,22 @@ 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. - * `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. - * `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. - * `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`). + * `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. + * `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. -5. **Configure Traefik Labels:** +4. **Configure Traefik Labels:** * Copy the example `labels` file: ```bash 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`. - -6. **Prepare Subscription Mapping File:** - * Create the `subscriptions.json` file (or edit the template provided). - * Add entries mapping your Flic button's serial number (as a lowercase string key) to the PWA `PushSubscription` object obtained in step 3. - ```json - { - "80:e4:da:70:xx:xx:xx:xx": { // <-- Replace with your actual Flic Button Serial (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 @@ -115,18 +89,13 @@ 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 \ --mount type=bind,src=./subscriptions.json,dst=/app/subscriptions.json \ flic-webhook-webpush:latest ``` - * `--network traefik`: Connects to the Traefik network. - * `--env-file .env`: Loads configuration from your `.env` file. - * `--label-file labels`: Applies the Traefik routing rules from your edited `labels` file. - * `--mount ...`: Makes your local `subscriptions.json` available inside the container at `/app/subscriptions.json`. `readonly` is recommended as the app only reads it. - * `flic-webhook-webpush:latest`: The image built in the previous step. 3. **Check Logs:** Monitor the container logs to ensure it started correctly and to see incoming webhook requests or errors. @@ -142,62 +111,137 @@ 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`). - ```json - { - "button_id": "{serialNumber}", - "click_type": "{clickType}", - "timestamp": "{timestamp}" - } - ``` - *(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). +2. Add an "Internet Request" action. +3. Fill in the following details: + * Select the `GET` method. + * Set URL with query parameter: `https:///webhook/SingleClick` (Replace `` with your actual service domain, e.g., `webpush.virtonline.eu`). + * **If Basic Authentication is enabled:** + * Set the Headers: + * Set the `Key` fields to `Authorization`. + * Set the `Value` fields to `Basic `. + * 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: + +Flic Button Request -## API Endpoint +## App Example: "HTTP Shortcuts" by waboodoo -* **`POST /flic-webhook`** - * **Description:** Receives Flic button events. - * **Authentication:** Optional Bearer token via `Authorization` header if `FLIC_SECRET` is configured. - * **Request Body (JSON):** +Search the Play Store - there might be others with similar names. + +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": "SERIAL_NUMBER_OF_FLIC_BUTTON", - "click_type": "SingleClick | DoubleClick | Hold", - "timestamp": "ISO_8601_TIMESTAMP_STRING (Optional)" + "button_id": "game-button", // Optional, defaults to DEFAULT_BUTTON_NAME environment variable + "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. + * **Authentication:** Optional Basic Authentication via `Authorization` header if `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` are configured. + * **URL Parameters:** + * `click_type` (required): The type of button press (e.g., `SingleClick`, `DoubleClick`, or `Hold`). + * **Required 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. + * **Optional Headers:** + * `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: + ```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. - * `400 Bad Request`: Missing `button_id` 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`. + * `400 Bad Request`: Missing `Button-Name` header or `click_type` URL parameter. + * `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`. * `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. -* **`GET /health`** (Optional) - * **Description:** Simple health check endpoint. - * **Response:** - ```json - { - "status": "UP", - "timestamp": "ISO_8601_TIMESTAMP_STRING" - } - ``` +## Testing the Webhook + +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 ``, `` with your actual values. The `Button-Name` header is optional and will default to the value of `DEFAULT_BUTTON_NAME` if not provided. + +```bash +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" +``` + +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 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. ## Troubleshooting * **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. * **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 `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). -* **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. -* **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`. \ No newline at end of file +* **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`. +* **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 diff --git a/images/flic-button-request.png b/images/flic-button-request.png new file mode 100644 index 0000000..0f609d9 Binary files /dev/null and b/images/flic-button-request.png differ diff --git a/images/interaction-flow.mmd b/images/interaction-flow.mmd new file mode 100644 index 0000000..700d01e --- /dev/null +++ b/images/interaction-flow.mmd @@ -0,0 +1,8 @@ +stateDiagram-v2 + [*] --> FlicButton : Button Press + FlicButton --> Bluetooth : Transmit Signal + Bluetooth --> Phone : Receive Signal + Phone --> WebhookWebPushService : Send Webhook + WebhookWebPushService --> GooglePushAPI : Forward Notification + GooglePushAPI --> PWA : Push Notification + PWA --> [*] : Handle Notification \ No newline at end of file diff --git a/images/interaction-flow.png b/images/interaction-flow.png new file mode 100644 index 0000000..6141bb9 Binary files /dev/null and b/images/interaction-flow.png differ diff --git a/images/subscription-flow.mmd b/images/subscription-flow.mmd new file mode 100644 index 0000000..cca194e --- /dev/null +++ b/images/subscription-flow.mmd @@ -0,0 +1,7 @@ +stateDiagram-v2 + [*] --> PWA : User Initiates + PWA --> GooglePushAPI : Request Subscription + GooglePushAPI --> PWA : Return Subscription Info + PWA --> WebhookWebPushService : Store Subscription Info + WebhookWebPushService --> PWA : Confirmation + [*] --> Complete \ No newline at end of file diff --git a/images/subscription-flow.png b/images/subscription-flow.png new file mode 100644 index 0000000..1a9f594 Binary files /dev/null and b/images/subscription-flow.png differ diff --git a/labels.example b/labels.example index 88fce42..b01410c 100644 --- a/labels.example +++ b/labels.example @@ -1,29 +1,32 @@ -# 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 +# Docker Network +traefik.docker.network=traefik -# --- 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 +# 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 +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 -# --- 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 +# Point the service to the container's port +traefik.http.services.flic-webhook-webpush.loadbalancer.server.port=3000 -# --- Docker Network --- -# Ensure Traefik uses the correct network to communicate with the container -traefik.docker.network=traefik \ No newline at end of file +# Middleware CORS +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.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.addvaryheader=true + +# Middleware Rate Limiting +traefik.http.middlewares.flic-ratelimit.ratelimit.average=10 +traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20 + +# Apply both middlewares to the router (comma-separated list) +traefik.http.routers.flic-webhook-webpush.middlewares=cors-headers,flic-ratelimit diff --git a/package-lock.json b/package-lock.json index f0ce88d..4960405 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 81120fa..a1e453d 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/server.js b/server.js index 2973f47..fc24ffe 100644 --- a/server.js +++ b/server.js @@ -1,8 +1,8 @@ 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 // Load environment variables from .env file require('dotenv').config(); @@ -12,18 +12,69 @@ const port = process.env.PORT || 3000; const vapidPublicKey = process.env.VAPID_PUBLIC_KEY; const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY; const vapidSubject = process.env.VAPID_SUBJECT; // mailto: or https: -const subscriptionsFilePath = process.env.SUBSCRIPTIONS_FILE || path.join(__dirname, 'subscriptions.json'); -const flicSecret = process.env.FLIC_SECRET; // Optional Bearer token secret for Flic webhook +const subscriptionsFilePath = process.env.SUBSCRIPTIONS_FILE || path.join(__dirname, '/app/subscriptions.json'); +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. // 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 +const firstRetryDelay = parseInt(process.env.NOTIFICATION_FIRST_RETRY_DELAY_MS || 10, 10); // 10 milliseconds - minimal delay for first retry +// HTTP request timeout configuration +const httpTimeout = parseInt(process.env.HTTP_TIMEOUT_MS || 10000, 10); // 10 seconds +// Logging level configuration +const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase(); +// --- Logging Utility --- +const LogLevels = { + error: 0, + warn: 1, + info: 2, + debug: 3 +}; + +const logger = { + error: (...args) => { + console.error(...args); + }, + warn: (...args) => { + if (LogLevels[LOG_LEVEL] >= LogLevels.warn) { + console.warn(...args); + } + }, + info: (...args) => { + if (LogLevels[LOG_LEVEL] >= LogLevels.info) { + console.log(...args); + } + }, + debug: (...args) => { + if (LogLevels[LOG_LEVEL] >= LogLevels.debug) { + console.log('DEBUG -', ...args); + } + } +}; + +// 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) { - console.error('Error: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must be set in the environment variables.'); + logger.error('Error: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must be set in the environment variables.'); process.exit(1); } @@ -34,17 +85,66 @@ 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 = subsequentRetryDelay) { + 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) { + // For first retry (retryCount = 0), use minimal delay or no delay + const actualDelay = retryCount === 0 ? firstRetryDelay : delay; + + if (retryCount === 0) { + 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})...`); + } + + // Wait for the delay (minimal or none for first retry) + 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 + 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 function loadSubscriptions() { if (!fs.existsSync(subscriptionsFilePath)) { - console.warn(`Warning: Subscriptions file not found at ${subscriptionsFilePath}. Creating an empty file.`); + logger.warn(`Warning: Subscriptions file not found at ${subscriptionsFilePath}. Creating an empty file.`); try { fs.writeFileSync(subscriptionsFilePath, '{}', 'utf8'); subscriptions = {}; } catch (err) { - console.error(`Error: Could not create subscriptions file at ${subscriptionsFilePath}.`, err); + logger.error(`Error: Could not create subscriptions file at ${subscriptionsFilePath}.`, err); // Exit or continue with empty object depending on desired robustness process.exit(1); // Exit if we can't even create the file } @@ -52,9 +152,9 @@ function loadSubscriptions() { try { const data = fs.readFileSync(subscriptionsFilePath, 'utf8'); subscriptions = JSON.parse(data || '{}'); // Handle empty file case - console.log(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`); + logger.info(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`); } catch (err) { - console.error(`Error reading or parsing subscriptions file at ${subscriptionsFilePath}. Please ensure it's valid JSON. Using empty cache.`, err); + logger.error(`Error reading or parsing subscriptions file at ${subscriptionsFilePath}. Please ensure it's valid JSON. Using empty cache.`, err); // Continue with empty subscriptions, but log the error subscriptions = {}; } @@ -64,9 +164,9 @@ function loadSubscriptions() { function saveSubscriptions() { try { fs.writeFileSync(subscriptionsFilePath, JSON.stringify(subscriptions, null, 2), 'utf8'); // Pretty print JSON - console.log(`Subscriptions successfully saved to ${subscriptionsFilePath}`); + logger.info(`Subscriptions successfully saved to ${subscriptionsFilePath}`); } catch (err) { - console.error(`Error writing subscriptions file: ${subscriptionsFilePath}`, err); + logger.error(`Error writing subscriptions file: ${subscriptionsFilePath}`, err); // Note: The in-memory object is updated, but persistence failed. } } @@ -78,64 +178,73 @@ 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('/flic-webhook', cors(corsOptions)); -app.options('/subscribe', cors(corsOptions)); - - // --- Body Parsing Middleware --- app.use(express.json()); -// --- Authentication Middleware (For Flic Webhook Only) --- -const authenticateFlicRequest = (req, res, next) => { - // Only apply auth if flicSecret is configured - if (!flicSecret) { +// --- Basic Authentication Middleware --- +const authenticateBasic = (req, res, next) => { + // 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') { + 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(); } const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - console.warn('Auth (Flic): Missing or malformed Authorization header'); - return res.status(401).json({ message: 'Unauthorized: Missing or malformed Bearer token' }); + + if (!authHeader || !authHeader.startsWith('Basic ')) { + 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]; - if (token !== flicSecret) { - console.warn('Auth (Flic): Invalid Bearer token received'); - return res.status(401).json({ message: 'Unauthorized: Invalid token' }); - } + const credentials = authHeader.split(' ')[1]; + const decodedCredentials = Buffer.from(credentials, 'base64').toString('utf8'); + const [username, password] = decodedCredentials.split(':'); - console.log('Auth (Flic): Request authenticated successfully.'); - next(); + // Note: Use constant-time comparison for production environments if possible, + // 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) => { - const { button_id, subscription } = req.body; +// --- Routes --- - console.log(`Received subscription request for button: ${button_id}`); +// Subscribe endpoint: Add a new button->subscription mapping +// Apply Basic Authentication +app.post('/subscribe', authenticateBasic, async (req, res) => { + let { button_id, subscription } = req.body; - // Basic Validation + logger.debug('All headers received on /subscribe:'); + Object.keys(req.headers).forEach(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() === '') { - console.warn('Subscription Error: Missing or invalid button_id'); - return res.status(400).json({ message: 'Bad Request: Missing or invalid button_id' }); + button_id = defaultButtonName; + logger.info(`No button_id provided, using default button name: ${button_id}`); } + + logger.info(`Received subscription request for button: ${button_id}`); + + // Basic Validation - now we only validate subscription since button_id will use default if not provided if (!subscription || typeof subscription !== 'object' || !subscription.endpoint || !subscription.keys || !subscription.keys.p256dh || !subscription.keys.auth) { - console.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' }); } @@ -143,7 +252,7 @@ app.post('/subscribe', async (req, res) => { // Update in-memory store subscriptions[normalizedButtonId] = subscription; - console.log(`Subscription for button ${normalizedButtonId} added/updated in memory.`); + logger.info(`Subscription for button ${normalizedButtonId} added/updated in memory.`); // Persist to file try { @@ -156,54 +265,69 @@ 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; +// --- Flic Webhook Endpoint (GET only) --- +// Apply Basic Authentication +app.get('/webhook/:click_type', authenticateBasic, async (req, res) => { + // Get buttonName from Header 'Button-Name' and timestamp from Header 'Timestamp' + const buttonName = req.headers['button-name'] || defaultButtonName; + const timestamp = req.headers['timestamp']; + // Get click_type from URL path + 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'; - console.log(`Received webhook: Button=${button_id}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`); + // Log all headers received from Flic + logger.debug('All headers received on /webhook:'); + Object.keys(req.headers).forEach(headerName => { + logger.debug(` ${headerName}: ${req.headers[headerName]}`); + }); + + logger.info(`Received GET webhook: Button=${buttonName}, Type=${click_type}, Battery=${batteryLevel}%, 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 (!click_type) { + logger.warn(`Webhook Error: Missing click_type query parameter`); + return res.status(400).json({ message: 'Bad Request: Missing click_type query parameter' }); } - 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}` }); + logger.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 - timestamp: timestamp || new Date().toISOString() + button: normalizedButtonName, // Send normalized button name + timestamp: timestamp || new Date().toISOString(), + batteryLevel: batteryLevel // Use the extracted value } // 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}.`); + logger.debug(`Subscription endpoint: ${subscription.endpoint}`); + logger.info(`Sending push notification for ${normalizedButtonName} to endpoint: ${subscription.endpoint.substring(0, 40)}...`); + await sendWebPushWithRetry(subscription, payload); + logger.info(`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); + logger.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.`); + logger.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 { @@ -212,44 +336,42 @@ app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => { } }); -// --- Health Check Endpoint --- -app.get('/health', (req, res) => { - res.status(200).json({ - status: 'UP', - timestamp: new Date().toISOString(), - subscription_count: Object.keys(subscriptions).length - }); -}); - // --- Start Server --- // Use http.createServer to allow graceful shutdown -const http = require('http'); const server = http.createServer(app); server.listen(port, () => { - console.log(`Flic Webhook to WebPush server listening on port ${port}`); - console.log(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`); - console.log(`Allowed Methods: ${allowedMethods.join(', ')}`); - console.log(`Allowed Headers: ${allowedHeaders.join(', ')}`); - console.log(`Flic Webhook Auth: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`); - console.log(`Subscription Endpoint Auth: Disabled`); - console.log(`Subscriptions File: ${subscriptionsFilePath}`); + logger.info(`Flic Webhook to WebPush server listening on port ${port}`); + logger.info('CORS: Handled by Traefik'); + // Log Basic Auth status instead of Flic Secret + if (basicAuthUsername && basicAuthPassword) { + logger.info('Authentication: Basic Auth Enabled'); + } 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(`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(`HTTP Timeout: ${httpTimeout}ms`); + logger.info(`Log Level: ${LOG_LEVEL.toUpperCase()}`); }); // --- Graceful Shutdown --- const closeGracefully = (signal) => { - console.log(`${signal} signal received: closing HTTP server`); + logger.info(`${signal} signal received: closing HTTP server`); server.close(() => { - console.log('HTTP server closed'); + logger.info('HTTP server closed'); // Perform any other cleanup here if needed process.exit(0); }); // Force close server after 10 seconds setTimeout(() => { - console.error('Could not close connections in time, forcefully shutting down'); + logger.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')); diff --git a/virt-flic-webhook-webpush.service.example b/virt-flic-webhook-webpush.service.example new file mode 100644 index 0000000..4bc8444 --- /dev/null +++ b/virt-flic-webhook-webpush.service.example @@ -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