From 5a3b4974c4a54565969a19233e77645a25b7d4eb Mon Sep 17 00:00:00 2001 From: cpu Date: Fri, 28 Mar 2025 17:28:24 +0100 Subject: [PATCH] changed the route name --- .env.example | 60 ++++++++-------- README.md | 110 +++++++++++++++++++++--------- server.js | 78 ++++++++++++++------- virt-flic-webhook-webpush.service | 31 +++++++++ 4 files changed, 191 insertions(+), 88 deletions(-) create mode 100644 virt-flic-webhook-webpush.service diff --git a/.env.example b/.env.example index 5ee6229..72d89ce 100644 --- a/.env.example +++ b/.env.example @@ -1,43 +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:admin@virtonline.eu # Contact email/URL for push service -# 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 --- +PORT=3000 # Internal port for the Node.js app +SUBSCRIPTIONS_FILE=subscriptions.json # Path inside the container -# Subscription Storage -SUBSCRIPTIONS_FILE=subscriptions.json +# --- 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 -# CORS +# --- CORS Configuration (Optional but Recommended) --- +# Comma-separated list of allowed origins for requests (e.g., your PWA frontend URL) +# If blank or not set, CORS might block browser requests (like from a setup page). +# Use '*' carefully, preferably list specific domains. ALLOWED_ORIGINS=https://game-timer.virtonline.eu -ALLOWED_METHODS=POST,OPTIONS,GET +ALLOWED_METHODS=POST,GET,OPTIONS ALLOWED_HEADERS=Content-Type,Authorization -# Logging Configuration -LOG_LEVEL=INFO +# --- Web Push Retry Configuration (Optional) --- +MAX_NOTIFICATION_RETRIES=3 # Number of retries on failure (e.g., DNS issues) +INITIAL_RETRY_DELAY_MS=1000 # Initial delay in milliseconds -# --- 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= +# --- Network Configuration (Optional) --- +DNS_TIMEOUT_MS=5000 # Timeout for DNS lookups (ms) +HTTP_TIMEOUT_MS=10000 # Timeout for outgoing HTTP requests (ms) -# --- DNS and Network Configuration --- -# These settings help with Docker DNS resolution issues (EAI_AGAIN errors) - -# Maximum number of retry attempts for failed DNS resolutions -MAX_NOTIFICATION_RETRIES=3 - -# Initial delay in milliseconds before first retry (will increase with backoff) -INITIAL_RETRY_DELAY_MS=1000 - -# DNS resolution timeout in milliseconds -DNS_TIMEOUT_MS=5000 - -# HTTP request timeout in milliseconds -HTTP_TIMEOUT_MS=10000 \ No newline at end of file +# --- Logging --- +# Controls log verbosity: error, warn, info, debug +LOG_LEVEL=info \ No newline at end of file diff --git a/README.md b/README.md index 69ad8f2..caa4930 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,16 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for ## 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. -* 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. * 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. +* CORS configuration for allowing requests (needed if your PWA management interface interacts with the `/subscribe` endpoint). ## Prerequisites @@ -61,7 +62,8 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for * `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. + * `BASIC_AUTH_USERNAME`: (Optional) Username for Basic Authentication. If set along with `BASIC_AUTH_PASSWORD`, authentication will be enabled for `/webhook` and `/subscribe`. + * `BASIC_AUTH_PASSWORD`: (Optional) Password for Basic Authentication. If set along with `BASIC_AUTH_USERNAME`, authentication will be enabled. * `ALLOWED_ORIGINS`: Comma-separated list of domains allowed by CORS. Include your PWA's domain if it needs to interact directly (e.g., for setup). Example: `https://my-pwa.com`. * `ALLOWED_METHODS`: (Default: `POST,OPTIONS`) Standard methods needed. * `ALLOWED_HEADERS`: (Default: `Content-Type,Authorization`) Standard headers needed. @@ -78,10 +80,6 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for ```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`. 5. **Prepare Subscription Mapping File:** * Edit the `subscriptions.json` file @@ -138,43 +136,91 @@ In your Flic app or Flic Hub SDK interface: 2. Add an "Internet Request" action. 3. Fill in the following details: * Set `GET` method. - * Set URL with query parameter: `https://webpush.virtonline.eu/flic-webhook?click_type=SingleClick` - * Add headers: - * Key: `Authorization` - * Value: `Bearer ` (Replace `` with the actual secret from your `.env` file). - * It should look like this: - - - + * 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 `Username` and `Password` fields to the values from your `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables. + * **If Basic Authentication is disabled:** + * Leave the `Username` and `Password` fields empty. * Tap on `Save action`. -4. Repeat for Double Click and/or Hold events. +4. Repeat for Double Click and/or Hold events, changing the `click_type` parameter accordingly (e.g., `/DoubleClick`). -## API Endpoint +## API Endpoints -* **`GET /flic-webhook`** +* **`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, required): The unique identifier for the Flic button (lowercase recommended, e.g., "game", "lights"). + * `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", + "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 `button_id` or `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 Bearer token via `Authorization` header if `FLIC_SECRET` is configured. - * **Query Parameters:** - * `click_type`: The type of button press (SingleClick, DoubleClick, or Hold) + * **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` (required): The identifier of the Flic button (sent by the Flic system). + * **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: + ```json + { + "action": "SingleClick", // or DoubleClick, Hold + "button": "game-button", // Normalized button name (lowercase) + "timestamp": "2024-03-28T15:00:00.000Z", // ISO 8601 timestamp or current server time + "batteryLevel": 100 // Integer percentage (0-100) or 'N/A' if not provided + } + ``` * **Responses:** * `200 OK`: Webhook received, push notification sent successfully. - * `400 Bad Request`: Missing `Button-Name` header or `click_type` query parameter. - * `401 Unauthorized`: Missing or invalid Bearer token (if `FLIC_SECRET` is enabled). + * `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. ## 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 ``, ``, ``, and `` with your actual values. ```bash -curl -X GET "https://webpush.virtonline.eu/flic-webhook?click_type=SingleClick" \ - -H "Authorization: Bearer a74181969a613c545d66c1e436e75c1e4a6" \ - -H "Button-Name: Game" \ - -H "Timestamp: 2025-03-26T01:10:20Z" +# Generate Base64 credentials (run this once) +# echo -n ':' | base64 + +# Example using generated Base64 string (replace YOUR_BASE64_CREDS) +curl -X GET "https:///webhook/SingleClick" \ + -H "Authorization: Basic YOUR_BASE64_CREDS" \ + -H "Button-Name: " \ + -H "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -H "Button-Battery-Level: 100" \ + +# Example using curl's built-in Basic Auth (-u) +curl -X GET "https:///webhook/SingleClick" \ + -u ":" \ + -H "Button-Name: " \ + -H "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -H "Button-Battery-Level: 100" \ ``` The expected response should be: @@ -197,5 +243,5 @@ If you receive a different response, refer to the Troubleshooting section below. * **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. +* **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`. \ No newline at end of file diff --git a/server.js b/server.js index 57728cd..e2ad2d3 100644 --- a/server.js +++ b/server.js @@ -14,7 +14,9 @@ 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 +// 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); @@ -182,37 +184,55 @@ const corsOptions = { }; app.use(cors(corsOptions)); // Enable pre-flight requests for all relevant routes -app.options('/flic-webhook', cors(corsOptions)); +app.options('/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) + if (req.method === 'OPTIONS') { + return next(); + } + + // 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 ')) { - logger.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) { - logger.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(':'); - logger.debug('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) => { +// --- Routes --- + +// Subscribe endpoint: Add a new button->subscription mapping +// Apply Basic Authentication +app.post('/subscribe', authenticateBasic, async (req, res) => { const { button_id, subscription } = req.body; logger.debug('All headers received:'); @@ -250,13 +270,17 @@ app.post('/subscribe', async (req, res) => { }); // --- Flic Webhook Endpoint (GET only) --- -// Apply Flic-specific authentication to this route -app.get('/flic-webhook', authenticateFlicRequest, async (req, res) => { +// 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']; const timestamp = req.headers['timestamp']; - // Get click_type from query parameter instead of request body - const click_type = req.query.click_type; + // 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'; // Log all headers received from Flic logger.debug('All headers received:'); @@ -289,7 +313,8 @@ app.get('/flic-webhook', authenticateFlicRequest, async (req, res) => { data: { action: click_type, 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' }); @@ -324,8 +349,13 @@ server.listen(port, () => { logger.info(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`); logger.info(`Allowed Methods: ${allowedMethods.join(', ')}`); logger.info(`Allowed Headers: ${allowedHeaders.join(', ')}`); - logger.info(`Flic Webhook Auth: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`); - logger.info(`Subscription Endpoint Auth: Disabled`); + // 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(`Subscriptions File: ${subscriptionsFilePath}`); logger.info(`Push Notification Retry Config: ${maxRetries} retries, ${initialRetryDelay}ms initial delay`); logger.info(`DNS Config: IPv4 first, timeout ${dnsTimeout}ms`); diff --git a/virt-flic-webhook-webpush.service b/virt-flic-webhook-webpush.service new file mode 100644 index 0000000..a914971 --- /dev/null +++ b/virt-flic-webhook-webpush.service @@ -0,0 +1,31 @@ +[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' + +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