This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
*
|
||||
*.dev.yml
|
||||
!Dockerfile
|
||||
!package.json
|
||||
!package-lock.json
|
||||
!src/
|
||||
!src/**
|
||||
|
||||
+24
-3
@@ -1,9 +1,30 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Generated output and app data
|
||||
media/
|
||||
docker-compose.dev.yml
|
||||
.vscode/
|
||||
coverage/
|
||||
dist/
|
||||
|
||||
# Local environment and compose overrides
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!**/.env.example*
|
||||
.env.local
|
||||
docker-compose/.env
|
||||
docker-compose/wg-easy/.env
|
||||
*.dev.yml
|
||||
|
||||
# Editor and OS files
|
||||
.vscode/
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
pnpm-debug.log*
|
||||
|
||||
# Tool caches
|
||||
.cache/
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.0.1 - 2026-07-28
|
||||
|
||||
### Changed
|
||||
|
||||
- The main README was updated to match the current compose layout, local build path, and database defaults.
|
||||
- Repository ignore rules were reorganized and expanded to cover local compose overrides, build output, and `*.dev.yml` files.
|
||||
- Added a Docker Compose split-setup note that points to `docker-compose/wg-easy/README.md` for the central-host, remote-player, and same-host compose files.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The package metadata and lockfile root version are now aligned at `2.0.1`.
|
||||
|
||||
## 2.0.0 - 2026-07-28
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -16,17 +16,18 @@ The project is built around playlists, slides, templates, canvas sizes, uploaded
|
||||
|
||||
The easiest way to run the stack is with Docker Compose.
|
||||
|
||||
1. Set any environment variables you want to override.
|
||||
2. Run `docker compose up -d`.
|
||||
3. Open the web admin app on port `8080` and the player on port `8081`.
|
||||
1. Download the `docker-compose/docker-compose.yml` and `docker-compose/.env.example` files from this repository.
|
||||
2. Save them in the same folder, copy `.env.example` to `.env`, and adjust any values you want to override.
|
||||
3. From that folder, run `docker compose --env-file .env -f docker-compose.yml up -d`.
|
||||
4. Open the web admin app on port `8080` and the player on port `8081`.
|
||||
|
||||
By default, the compose file uses `git.lzstealth.com/lzstealth/pulse-signage:latest` for both app services.
|
||||
By default, that compose file uses `git.lzstealth.com/lzstealth/pulse-signage:latest` for both app services.
|
||||
|
||||
Before starting, copy `.env.example` to `.env` and adjust the values for your setup.
|
||||
For a split setup with a separate player host, see [docker-compose/wg-easy/README.md](docker-compose/wg-easy/README.md).
|
||||
|
||||
## Docker Compose
|
||||
|
||||
The included `docker-compose.yml` starts three services:
|
||||
The included `docker-compose/docker-compose.yml` starts three services:
|
||||
|
||||
- `web` - the admin app on port `8080`
|
||||
- `player` - the signage player on port `8081`
|
||||
@@ -76,8 +77,7 @@ The default database container uses MySQL 8.4, but the app can also run against
|
||||
- `PLAYER_IDENTIFIER` - stable player identity used for the registry row and duplicate-player protection. Keep this the same across restarts for the same physical player.
|
||||
- `PLAYER_INTERNAL_BASE_URL` - player address stored in `d_players.internal_base_url` for web-to-player requests, default `http://player:8081` in Docker
|
||||
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links and onboarding status responses, default `http://localhost:8081`
|
||||
- `WEB_PORT` - admin app port when you run the service outside Docker, default `8080`
|
||||
- `PLAYER_PORT` - player port when you run the service outside Docker, default `8081`
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests. This should be the same as the secret on the web.
|
||||
|
||||
### Documentation
|
||||
|
||||
@@ -85,9 +85,20 @@ The default database container uses MySQL 8.4, but the app can also run against
|
||||
- [Database schema](docs/schema.md)
|
||||
- [Player websocket behavior](docs/websocket.md)
|
||||
|
||||
### Remote Player Deployment
|
||||
|
||||
If a player runs on a different machine or network, keep the database private and connect the player to it through a self-hosted VPN. In practice, that means the central host runs `web`, `mysql`, and the VPN server container, while each player host runs the `player` container plus a VPN client container.
|
||||
|
||||
The player should use the database host's private VPN address for `DB_HOST`, and MySQL does not need a public `3306` mapping. Keep the VPN container and the app containers on the same Docker host, avoid publishing the MySQL port to the public internet, use a stable private address or private DNS name for the database host, and keep `PULSE_SIGNAGE_SHARED_SECRET` identical on the web and player services.
|
||||
|
||||
This repo does not bundle a VPN stack yet, but the app already supports a private database host through `DB_HOST` and `DB_PORT`, so a WireGuard-style container pair or another self-hosted tunnel can be added without changing the application itself. If you ever need more than one player, the same pattern applies: give each player a unique `PLAYER_IDENTIFIER`, keep `PLAYER_INTERNAL_BASE_URL` and `PLAYER_PUBLIC_BASE_URL` pointed at the right address for that player, and publish each player on its own host port only if you need direct access. It is supported, but generally not needed.
|
||||
|
||||
See [docker-compose/wg-easy/](docker-compose/wg-easy/) for a UI-based wg-easy example that generates per-player config files. Copy `docker-compose/wg-easy/.env.example` to `docker-compose/wg-easy/.env` before using it.
|
||||
|
||||
See [docker-compose/README.md](docker-compose/README.md) for the current compose entry points and environment file layout.
|
||||
|
||||
### Notes
|
||||
|
||||
- The web app reaches players through the internal base URL stored in `d_players.internal_base_url`.
|
||||
- In Docker, the player service should publish its own internal and public base URLs so the registry row stays accurate.
|
||||
- The web and player services can run separately, and they do not need to share the same upload mount as long as each service can access its own configured media path.
|
||||
- If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player services must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Docker Compose
|
||||
|
||||
This folder groups the Docker Compose entry points by deployment shape.
|
||||
|
||||
Current subfolders:
|
||||
|
||||
- `docker-compose.yml` - default local stack with the web app, player, and MySQL
|
||||
- `wg-easy/` - central host plus player example for the WireGuard-based setup
|
||||
|
||||
Each subfolder is self-contained and includes its own compose files, env example, and any supporting files it needs. The expected env files are `docker-compose/.env` for the default stack and `docker-compose/wg-easy/.env` for the WireGuard example.
|
||||
@@ -1,3 +1,5 @@
|
||||
name: pulse-signage
|
||||
|
||||
services:
|
||||
web:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Shared app settings for the central host.
|
||||
# `PULSE_SIGNAGE_IMAGE` can point at a registry image or a locally built one.
|
||||
PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
|
||||
# Shared secret used by the web app and player-side requests.
|
||||
PULSE_SIGNAGE_SHARED_SECRET=
|
||||
|
||||
# Central host app and database settings.
|
||||
# These values are used by the web app and the central MySQL container.
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_NAME=signage
|
||||
DB_USER=signage_user
|
||||
DB_PASSWORD=signage_password
|
||||
MYSQL_ROOT_PASSWORD=root_password
|
||||
|
||||
# Web app defaults for the first admin account and password hashing.
|
||||
SESSION_MAX_AGE_DAYS=14
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_NAME=Admin
|
||||
DEFAULT_ADMIN_PASSWORD=admin
|
||||
PASSWORD_HASH_ITERATIONS=310000
|
||||
|
||||
# wg-easy central host settings.
|
||||
# `WG_HOST` should be the public hostname or IP players use to reach this host.
|
||||
WG_HOST=vpn.example.com
|
||||
WG_EASY_PASSWORD_HASH=$$2b$$12$$replace_this_with_a_real_bcrypt_hash
|
||||
WG_DEFAULT_ADDRESS=10.13.13.x
|
||||
WG_DEFAULT_DNS=1.1.1.1
|
||||
WG_ALLOWED_IPS=10.13.13.0/24
|
||||
WG_PERSISTENT_KEEPALIVE=25
|
||||
|
||||
# Player host settings.
|
||||
# `PLAYER_IDENTIFIER` keeps the player record stable across restarts.
|
||||
PLAYER_IDENTIFIER=player-01
|
||||
PLAYER_PUBLIC_BASE_URL=http://localhost:8081
|
||||
PLAYER_INTERNAL_BASE_URL=http://10.13.13.2:8081
|
||||
|
||||
# Player-to-central DB settings.
|
||||
# These values let the player reach the central database through the VPN.
|
||||
PLAYER_DB_HOST=10.13.13.1
|
||||
PLAYER_DB_PORT=3306
|
||||
PLAYER_DB_NAME=signage
|
||||
PLAYER_DB_USER=signage_user
|
||||
PLAYER_DB_PASSWORD=signage_password
|
||||
@@ -0,0 +1,70 @@
|
||||
# wg-easy Compose Example
|
||||
|
||||
This folder includes two ways to use wg-easy with Pulse Signage:
|
||||
|
||||
- a remote-player setup, where the player runs on another host and connects back over VPN
|
||||
- a same-host setup, where the player runs on the same Docker host as the admin app and MySQL
|
||||
|
||||
## Files
|
||||
|
||||
- `docker-compose.yml` - remote-player example with the admin app, MySQL, and wg-easy
|
||||
- `docker-compose.player.yml` - remote player host with the player app and WireGuard client
|
||||
- `docker-compose.local.yml` - same-host example with the admin app, MySQL, wg-easy, and a local player
|
||||
- `docker/` - custom wg-easy image files used by the central host
|
||||
|
||||
## Choose the Right File
|
||||
|
||||
Use `docker-compose.yml` and `docker-compose.player.yml` if the player runs on a different machine or network.
|
||||
|
||||
Use `docker-compose.local.yml` if the player runs on the same Docker host as the admin app and MySQL, but you still want wg-easy available for remote players too.
|
||||
|
||||
## What You Need
|
||||
|
||||
- Docker and Docker Compose on the host or hosts you plan to run
|
||||
- A reachable public hostname or IP for the wg-easy server
|
||||
- A `.env` file beside the compose files
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy `./.env.example` to `./.env`.
|
||||
2. Set `WG_HOST` to the public address players will use.
|
||||
3. Set `WG_EASY_PASSWORD_HASH` to a bcrypt hash for the wg-easy UI.
|
||||
4. Start the compose file that matches your setup.
|
||||
5. Open the wg-easy UI and create peers for any remote players you want to add.
|
||||
6. Download the generated client config and copy it to the player host as `./wg0.conf`.
|
||||
7. Start the remote player host stack, or the same-host stack if you are using `docker-compose.local.yml`.
|
||||
|
||||
## How It Works
|
||||
|
||||
The remote-player example keeps MySQL private on the central Docker network and uses the custom `wg-easy` image to handle forwarding automatically. You do not need to add manual `PostUp` or `PostDown` hooks.
|
||||
|
||||
The same-host example keeps the player on the same Docker network as the admin app and MySQL, while still running wg-easy for any remote players you may add later.
|
||||
|
||||
## Settings to Know
|
||||
|
||||
- `WG_HOST` - the public host name or IP for the wg-easy server
|
||||
- `WG_EASY_PASSWORD_HASH` - the hashed password for the wg-easy UI
|
||||
- `WG_DEFAULT_ADDRESS` - the VPN subnet used for player addresses, such as `10.13.13.x`
|
||||
- `PLAYER_IDENTIFIER` - a stable ID for each player
|
||||
- `PLAYER_PUBLIC_BASE_URL` - the address browsers should use to reach a player
|
||||
- `PLAYER_INTERNAL_BASE_URL` - the VPN or internal address used by the central host to reach a player
|
||||
- `PLAYER_DB_HOST` - the private database host address used by the player
|
||||
|
||||
## Player Setup
|
||||
|
||||
For remote players:
|
||||
|
||||
1. Create a new peer in the wg-easy UI.
|
||||
2. Download the `.conf` file.
|
||||
3. Copy it to the player host as `./wg0.conf`.
|
||||
4. Make sure the player host has its own `PLAYER_IDENTIFIER` and matching base URLs.
|
||||
5. Start the remote player compose file.
|
||||
|
||||
For same-host players:
|
||||
|
||||
1. Start `docker-compose.local.yml`.
|
||||
2. Use the player service that is already part of that stack.
|
||||
3. Give the player its own identifier if you need to change it.
|
||||
4. Use the same public and internal URLs that match the local host.
|
||||
|
||||
If you ever need more than one player, give each one a unique identifier and a matching public/internal URL. It is supported, but most setups only need one player per host.
|
||||
@@ -0,0 +1,111 @@
|
||||
name: pulse-signage-central
|
||||
|
||||
services:
|
||||
web:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
container_name: signage-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
||||
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
||||
volumes:
|
||||
- media:/app/media
|
||||
command: ["node", "src/web.js"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- pulse_signage
|
||||
|
||||
player:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
container_name: signage-player
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-local}
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
|
||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
volumes:
|
||||
- media:/app/media
|
||||
command: ["node", "src/player.js"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- pulse_signage
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: signage-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME:-signage}
|
||||
MYSQL_USER: ${DB_USER:-signage_user}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- pulse_signage
|
||||
|
||||
wg-easy:
|
||||
build:
|
||||
context: ./docker
|
||||
image: pulse-signage-wg-easy:latest
|
||||
container_name: signage-wg-easy
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
ports:
|
||||
- "51820:51820/udp"
|
||||
- "51821:51821"
|
||||
environment:
|
||||
WG_HOST: ${WG_HOST}
|
||||
PASSWORD_HASH: ${WG_EASY_PASSWORD_HASH}
|
||||
WG_PORT: 51820
|
||||
WG_DEFAULT_ADDRESS: ${WG_DEFAULT_ADDRESS:-10.13.13.x}
|
||||
WG_DEFAULT_DNS: ${WG_DEFAULT_DNS:-1.1.1.1}
|
||||
WG_ALLOWED_IPS: ${WG_ALLOWED_IPS:-10.13.13.0/24}
|
||||
WG_PERSISTENT_KEEPALIVE: ${WG_PERSISTENT_KEEPALIVE:-25}
|
||||
volumes:
|
||||
- wg_easy_data:/etc/wireguard
|
||||
networks:
|
||||
- pulse_signage
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
media:
|
||||
wg_easy_data:
|
||||
|
||||
networks:
|
||||
pulse_signage:
|
||||
name: pulse_signage
|
||||
external: false
|
||||
@@ -0,0 +1,45 @@
|
||||
name: pulse-signage-player
|
||||
|
||||
services:
|
||||
vpn:
|
||||
image: lscr.io/linuxserver/wireguard:latest
|
||||
container_name: signage-wireguard-client
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
volumes:
|
||||
- ./wg0.conf:/config/wg_confs/wg0.conf:ro
|
||||
ports:
|
||||
- "8081:8081"
|
||||
networks:
|
||||
- player_net
|
||||
|
||||
player:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
container_name: signage-player
|
||||
restart: unless-stopped
|
||||
network_mode: service:vpn
|
||||
environment:
|
||||
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-}
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
|
||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://10.13.13.2:8081}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
DB_HOST: ${PLAYER_DB_HOST:-10.13.13.1}
|
||||
DB_PORT: ${PLAYER_DB_PORT:-3306}
|
||||
DB_NAME: ${PLAYER_DB_NAME:-signage}
|
||||
DB_USER: ${PLAYER_DB_USER:-signage_user}
|
||||
DB_PASSWORD: ${PLAYER_DB_PASSWORD:-signage_password}
|
||||
volumes:
|
||||
- media:/app/media
|
||||
command: ["node", "src/player.js"]
|
||||
depends_on:
|
||||
- vpn
|
||||
|
||||
volumes:
|
||||
media:
|
||||
|
||||
networks:
|
||||
player_net:
|
||||
name: player_net
|
||||
external: false
|
||||
@@ -0,0 +1,86 @@
|
||||
name: pulse-signage-central
|
||||
|
||||
services:
|
||||
web:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
container_name: signage-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
||||
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
||||
volumes:
|
||||
- media:/app/media
|
||||
command: ["node", "src/web.js"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- pulse_signage
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: signage-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME:-signage}
|
||||
MYSQL_USER: ${DB_USER:-signage_user}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- pulse_signage
|
||||
|
||||
wg-easy:
|
||||
build:
|
||||
context: ./docker
|
||||
image: pulse-signage-wg-easy:latest
|
||||
container_name: signage-wg-easy
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
ports:
|
||||
- "51820:51820/udp"
|
||||
- "51821:51821"
|
||||
environment:
|
||||
WG_HOST: ${WG_HOST}
|
||||
PASSWORD_HASH: ${WG_EASY_PASSWORD_HASH}
|
||||
WG_PORT: 51820
|
||||
WG_DEFAULT_ADDRESS: ${WG_DEFAULT_ADDRESS:-10.13.13.x}
|
||||
WG_DEFAULT_DNS: ${WG_DEFAULT_DNS:-1.1.1.1}
|
||||
WG_ALLOWED_IPS: ${WG_ALLOWED_IPS:-10.13.13.0/24}
|
||||
WG_PERSISTENT_KEEPALIVE: ${WG_PERSISTENT_KEEPALIVE:-25}
|
||||
volumes:
|
||||
- wg_easy_data:/etc/wireguard
|
||||
networks:
|
||||
- pulse_signage
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
media:
|
||||
wg_easy_data:
|
||||
|
||||
networks:
|
||||
pulse_signage:
|
||||
name: pulse_signage
|
||||
external: false
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM ghcr.io/wg-easy/wg-easy:latest
|
||||
|
||||
RUN apk add --no-cache python3
|
||||
|
||||
COPY install-wg-hooks.py /scripts/install-wg-hooks.py
|
||||
CMD ["python3", "-u", "/scripts/install-wg-hooks.py"]
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
CONF_PATH = os.environ.get("WG_EASY_CONF_PATH", "/etc/wireguard/wg0.conf")
|
||||
SERVER_VPN_IP = os.environ.get("SERVER_VPN_IP", "10.13.13.1")
|
||||
SERVER_PORT = os.environ.get("SERVER_PORT", "3306")
|
||||
POST_UP_TEMPLATE = (
|
||||
"iptables -t nat -A PREROUTING -i wg0 -d {server_ip} -p tcp --dport {port} "
|
||||
"-j DNAT --to-destination {mysql_ip}:{port}; "
|
||||
"iptables -A FORWARD -i wg0 -p tcp -d {mysql_ip} --dport {port} -j ACCEPT; "
|
||||
"iptables -t nat -A POSTROUTING -s 10.13.13.0/24 -d {mysql_ip} -p tcp --dport {port} -j MASQUERADE"
|
||||
)
|
||||
POST_DOWN_TEMPLATE = (
|
||||
"iptables -t nat -D PREROUTING -i wg0 -d {server_ip} -p tcp --dport {port} "
|
||||
"-j DNAT --to-destination {mysql_ip}:{port}; "
|
||||
"iptables -D FORWARD -i wg0 -p tcp -d {mysql_ip} --dport {port} -j ACCEPT; "
|
||||
"iptables -t nat -D POSTROUTING -s 10.13.13.0/24 -d {mysql_ip} -p tcp --dport {port} -j MASQUERADE"
|
||||
)
|
||||
|
||||
WG_EASY_PROCESS: subprocess.Popen[str] | None = None
|
||||
|
||||
|
||||
def wait_for_conf(timeout_seconds: int = 120) -> None:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
if os.path.exists(CONF_PATH):
|
||||
return
|
||||
time.sleep(2)
|
||||
raise RuntimeError(f"Timed out waiting for {CONF_PATH}")
|
||||
|
||||
|
||||
def get_mysql_ip() -> str:
|
||||
result = subprocess.run(
|
||||
["sh", "-lc", "getent hosts mysql | awk '{print $1}' | head -n1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
mysql_ip = result.stdout.strip()
|
||||
if not mysql_ip:
|
||||
raise RuntimeError("Could not resolve mysql container IP")
|
||||
return mysql_ip
|
||||
|
||||
|
||||
def patch_conf(mysql_ip: str) -> None:
|
||||
post_up = POST_UP_TEMPLATE.format(
|
||||
server_ip=SERVER_VPN_IP,
|
||||
port=SERVER_PORT,
|
||||
mysql_ip=mysql_ip,
|
||||
)
|
||||
post_down = POST_DOWN_TEMPLATE.format(
|
||||
server_ip=SERVER_VPN_IP,
|
||||
port=SERVER_PORT,
|
||||
mysql_ip=mysql_ip,
|
||||
)
|
||||
with open(CONF_PATH, encoding="utf-8") as handle:
|
||||
content = handle.read()
|
||||
|
||||
updated = content
|
||||
updated = updated.replace(
|
||||
"PostUp = iptables -t nat -A POSTROUTING -s 10.13.13.0/24 -o eth0 -j MASQUERADE; iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT;",
|
||||
f"PostUp = iptables -t nat -A POSTROUTING -s 10.13.13.0/24 -o eth0 -j MASQUERADE; iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; {post_up};",
|
||||
)
|
||||
updated = updated.replace(
|
||||
"PostDown = iptables -t nat -D POSTROUTING -s 10.13.13.0/24 -o eth0 -j MASQUERADE; iptables -D INPUT -p udp -m udp --dport 51820 -j ACCEPT; iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT;",
|
||||
f"PostDown = iptables -t nat -D POSTROUTING -s 10.13.13.0/24 -o eth0 -j MASQUERADE; iptables -D INPUT -p udp -m udp --dport 51820 -j ACCEPT; iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; {post_down};",
|
||||
)
|
||||
|
||||
if updated == content:
|
||||
raise RuntimeError("Did not find default hook lines to patch")
|
||||
|
||||
with open(CONF_PATH, "w", encoding="utf-8") as handle:
|
||||
handle.write(updated)
|
||||
|
||||
print(f"Patched {CONF_PATH} for MySQL forwarding to {mysql_ip}.", flush=True)
|
||||
|
||||
|
||||
def apply_forwarding_rules(mysql_ip: str) -> None:
|
||||
commands = [
|
||||
f"iptables -t nat -C PREROUTING -i wg0 -d {SERVER_VPN_IP} -p tcp --dport {SERVER_PORT} -j DNAT --to-destination {mysql_ip}:{SERVER_PORT} || iptables -t nat -A PREROUTING -i wg0 -d {SERVER_VPN_IP} -p tcp --dport {SERVER_PORT} -j DNAT --to-destination {mysql_ip}:{SERVER_PORT}",
|
||||
f"iptables -C FORWARD -i wg0 -p tcp -d {mysql_ip} --dport {SERVER_PORT} -j ACCEPT || iptables -A FORWARD -i wg0 -p tcp -d {mysql_ip} --dport {SERVER_PORT} -j ACCEPT",
|
||||
f"iptables -t nat -C POSTROUTING -s 10.13.13.0/24 -d {mysql_ip} -p tcp --dport {SERVER_PORT} -j MASQUERADE || iptables -t nat -A POSTROUTING -s 10.13.13.0/24 -d {mysql_ip} -p tcp --dport {SERVER_PORT} -j MASQUERADE",
|
||||
]
|
||||
|
||||
for command in commands:
|
||||
subprocess.run(["sh", "-lc", command], check=True)
|
||||
|
||||
print(f"Applied live forwarding rules for MySQL at {mysql_ip}.", flush=True)
|
||||
|
||||
|
||||
def current_signature() -> tuple[int, int]:
|
||||
stat = os.stat(CONF_PATH)
|
||||
return stat.st_mtime_ns, stat.st_size
|
||||
|
||||
|
||||
def should_patch(mysql_ip: str) -> bool:
|
||||
with open(CONF_PATH, encoding="utf-8") as handle:
|
||||
content = handle.read()
|
||||
return mysql_ip not in content or "3306" not in content or "--to-destination" not in content
|
||||
|
||||
|
||||
def start_wg_easy() -> subprocess.Popen[str]:
|
||||
return subprocess.Popen(["node", "server.js"])
|
||||
|
||||
|
||||
def ensure_wg_easy_running() -> None:
|
||||
global WG_EASY_PROCESS
|
||||
|
||||
if WG_EASY_PROCESS is None:
|
||||
WG_EASY_PROCESS = start_wg_easy()
|
||||
return
|
||||
|
||||
if WG_EASY_PROCESS.poll() is not None:
|
||||
print("wg-easy exited; restarting node server.js.", flush=True)
|
||||
WG_EASY_PROCESS = start_wg_easy()
|
||||
|
||||
|
||||
def watch_and_repair() -> None:
|
||||
last_signature: tuple[int, int] | None = None
|
||||
last_mysql_ip: str | None = None
|
||||
|
||||
while True:
|
||||
ensure_wg_easy_running()
|
||||
mysql_ip = get_mysql_ip()
|
||||
if mysql_ip != last_mysql_ip:
|
||||
last_mysql_ip = mysql_ip
|
||||
apply_forwarding_rules(mysql_ip)
|
||||
signature = current_signature()
|
||||
if signature != last_signature:
|
||||
last_signature = signature
|
||||
if should_patch(mysql_ip):
|
||||
patch_conf(mysql_ip)
|
||||
else:
|
||||
print("wg0.conf already contains the MySQL forwarding hooks.", flush=True)
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
global WG_EASY_PROCESS
|
||||
|
||||
WG_EASY_PROCESS = start_wg_easy()
|
||||
|
||||
def terminate_wg_easy(*_args: object) -> None:
|
||||
if WG_EASY_PROCESS is not None and WG_EASY_PROCESS.poll() is None:
|
||||
WG_EASY_PROCESS.terminate()
|
||||
|
||||
signal.signal(signal.SIGTERM, terminate_wg_easy)
|
||||
signal.signal(signal.SIGINT, terminate_wg_easy)
|
||||
|
||||
ensure_wg_easy_running()
|
||||
|
||||
wait_for_conf()
|
||||
mysql_ip = get_mysql_ip()
|
||||
patch_conf(mysql_ip)
|
||||
apply_forwarding_rules(mysql_ip)
|
||||
watch_and_repair()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as error:
|
||||
print(error, file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.16",
|
||||
"version": "2.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.16",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"bootstrap-icons": "1.11.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
|
||||
+69
-1
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const net = require('net');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const common = require('./common');
|
||||
@@ -136,6 +137,72 @@ async function waitForDatabaseBootstrap(pool) {
|
||||
}
|
||||
}
|
||||
|
||||
function isTransientDatabaseConnectionError(error) {
|
||||
return Boolean(
|
||||
error &&
|
||||
['ECONNREFUSED', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH'].includes(error.code)
|
||||
);
|
||||
}
|
||||
|
||||
async function bootstrapDatabaseWithRetry(pool, options) {
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await common.bootstrapDatabase(pool, options);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isTransientDatabaseConnectionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempt === 0 || attempt % 10 === 0) {
|
||||
console.log('Waiting for database connection...');
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
await delay(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTcpPort(host, port) {
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
const reachable = await new Promise(function (resolve) {
|
||||
const socket = net.createConnection({ host: host, port: port });
|
||||
const finish = function (value) {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
socket.setTimeout(1000);
|
||||
socket.once('connect', function () {
|
||||
finish(true);
|
||||
});
|
||||
socket.once('timeout', function () {
|
||||
finish(false);
|
||||
});
|
||||
socket.once('error', function () {
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (reachable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt === 0 || attempt % 10 === 0) {
|
||||
console.log(`Waiting for ${host}:${port} to become available...`);
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
await delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Player runtime, media API, and websocket wiring.
|
||||
async function start() {
|
||||
const app = express();
|
||||
@@ -192,7 +259,8 @@ async function start() {
|
||||
});
|
||||
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
await common.bootstrapDatabase(pool, { mediaDir: MEDIA_DIR });
|
||||
await waitForTcpPort(process.env.DB_HOST || '10.13.13.1', Number(process.env.DB_PORT || 3306));
|
||||
await bootstrapDatabaseWithRetry(pool, { mediaDir: MEDIA_DIR });
|
||||
|
||||
async function syncDatabaseState() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user