Release v2.0.1
Publish Docker Image / build-and-push (push) Successful in 1m14s

This commit is contained in:
2026-07-28 15:00:55 +01:00
parent 4a78648bea
commit df447c1299
17 changed files with 676 additions and 16 deletions
+3
View File
@@ -1,4 +1,7 @@
*
*.dev.yml
!Dockerfile
!package.json
!package-lock.json
!src/
!src/**
+24 -3
View File
@@ -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/
+12
View File
@@ -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
+20 -9
View File
@@ -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.
+10
View File
@@ -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}
+44
View File
@@ -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
+70
View File
@@ -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
+86
View File
@@ -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
+6
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 {