From 37a8586d34bc68a21260a2eaf5969a3e1936259d Mon Sep 17 00:00:00 2001 From: Jason Eugene Garrison Date: Sat, 11 Jul 2026 10:44:42 -0500 Subject: [PATCH] Create MeshCentral workspace --- .gitignore | 19 + README.md | 44 + bridge/meshpress-bridge/CHANGELOG.md | 109 + bridge/meshpress-bridge/README.md | 184 ++ bridge/meshpress-bridge/install.sh | 117 + bridge/meshpress-bridge/meshpress-bridge | 7 + bridge/meshpress-bridge/package.json | 15 + bridge/meshpress-bridge/server.js | 2063 +++++++++++++++++ bridge/meshpress-bridge/update.sh | 109 + .../gracepress-theme-studio.changelog.md | 19 + .../gracepress-theme-studio.download.php | 57 + dist/current/gracepress-theme-studio.json | 29 + .../gracepress-theme-studio.mcplugin.json | 26 + .../current/gracepress-theme-studio.tags.json | 20 + dist/current/gracepress.changelog.md | 91 + dist/current/gracepress.download.php | 57 + dist/current/gracepress.json | 29 + dist/current/gracepress.mcplugin.json | 26 + dist/current/gracepress.tags.json | 98 + dist/current/mesh-press.changelog.md | 163 ++ dist/current/mesh-press.json | 17 + dist/current/meshpress-bridge.changelog.md | 109 + dist/current/meshpress-bridge.json | 17 + docs/workspace-map.md | 26 + .../gracepress-theme-studio/CHANGELOG.md | 19 + .../gracepress-theme-studio/config.json | 19 + .../default-theme.json | 73 + .../gracepress-theme-studio/gpthemestudio.js | 685 ++++++ meshcentral/plugins/gracepress/CHANGELOG.md | 91 + meshcentral/plugins/gracepress/config.json | 19 + meshcentral/plugins/gracepress/gracepress.js | 562 +++++ vendor/README.md | 5 + wordpress/plugins/mesh-press/CHANGELOG.md | 163 ++ wordpress/plugins/mesh-press/mesh-press.php | 1843 +++++++++++++++ 34 files changed, 6930 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bridge/meshpress-bridge/CHANGELOG.md create mode 100644 bridge/meshpress-bridge/README.md create mode 100644 bridge/meshpress-bridge/install.sh create mode 100644 bridge/meshpress-bridge/meshpress-bridge create mode 100644 bridge/meshpress-bridge/package.json create mode 100644 bridge/meshpress-bridge/server.js create mode 100644 bridge/meshpress-bridge/update.sh create mode 100644 dist/current/gracepress-theme-studio.changelog.md create mode 100644 dist/current/gracepress-theme-studio.download.php create mode 100644 dist/current/gracepress-theme-studio.json create mode 100644 dist/current/gracepress-theme-studio.mcplugin.json create mode 100644 dist/current/gracepress-theme-studio.tags.json create mode 100644 dist/current/gracepress.changelog.md create mode 100644 dist/current/gracepress.download.php create mode 100644 dist/current/gracepress.json create mode 100644 dist/current/gracepress.mcplugin.json create mode 100644 dist/current/gracepress.tags.json create mode 100644 dist/current/mesh-press.changelog.md create mode 100644 dist/current/mesh-press.json create mode 100644 dist/current/meshpress-bridge.changelog.md create mode 100644 dist/current/meshpress-bridge.json create mode 100644 docs/workspace-map.md create mode 100644 meshcentral/plugins/gracepress-theme-studio/CHANGELOG.md create mode 100644 meshcentral/plugins/gracepress-theme-studio/config.json create mode 100644 meshcentral/plugins/gracepress-theme-studio/default-theme.json create mode 100644 meshcentral/plugins/gracepress-theme-studio/gpthemestudio.js create mode 100644 meshcentral/plugins/gracepress/CHANGELOG.md create mode 100644 meshcentral/plugins/gracepress/config.json create mode 100644 meshcentral/plugins/gracepress/gracepress.js create mode 100644 vendor/README.md create mode 100644 wordpress/plugins/mesh-press/CHANGELOG.md create mode 100644 wordpress/plugins/mesh-press/mesh-press.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e15c059 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules/ +npm-debug.log* + +.env +*.env + +vendor/MeshCentral/ + +dist/packages/ +*.zip + +.old/ +*.old +*.old-* +*.bak.* +*..old + +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb59fed --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# MeshCentral Workspace + +This repository collects Christ IT's MeshCentral-related work in one place. + +The workspace is intentionally source-first. It contains the code we own around MeshCentral operations, WordPress control, and MeshCentral plugin distribution. A full upstream MeshCentral checkout should stay outside this repository unless a specific patch or fork is being maintained. + +## Project Lanes + +### WordPress Control + +`wordpress/plugins/mesh-press/` + +MeshPress is the WordPress-side control plugin. It talks to the bridge service, caches fleet state, exposes MeshCentral group/device workflows, receives protected bridge events, and can forward useful lifecycle events into OmniLog. + +### Bridge Service + +`bridge/meshpress-bridge/` + +MeshPress Bridge is the Node/Express service that runs beside MeshCentral. It talks to MeshCentral through `meshctrl`, exposes token-protected API routes to MeshPress, pushes fleet snapshots to WordPress, and gates higher-risk operations behind explicit environment flags. + +### MeshCentral Plugins + +`meshcentral/plugins/` + +These are plugins that run inside MeshCentral itself. + +- `gracepress/` is the GracePress Catalog Connector for MeshCentral plugin delivery and diagnostics. +- `gracepress-theme-studio/` is the first managed MeshCentral plugin, currently focused on theme profile editing. + +### Current Distribution Artifacts + +`dist/current/` + +This folder keeps the current generated manifests, changelog copies, and MeshCentral facade files imported from the GracePress catalog. These are copied here as release evidence and migration reference. The long-term goal is for this workspace to own its own build/release scripts instead of depending on the GracePress workspace. + +## External References + +`vendor/` + +Use this folder for notes about external dependencies or local clones. Do not commit a full upstream MeshCentral source tree by default. + +## Current Migration Rule + +GracePress remains the active release/catalog source until this workspace has equivalent build and ship tooling. Do not delete the original GracePress copies until the new repository can build, package, and publish the same artifacts. diff --git a/bridge/meshpress-bridge/CHANGELOG.md b/bridge/meshpress-bridge/CHANGELOG.md new file mode 100644 index 0000000..eca85c6 --- /dev/null +++ b/bridge/meshpress-bridge/CHANGELOG.md @@ -0,0 +1,109 @@ +# meshpress-bridge Changelog + +## Current +- Fixed agent-link generation to pass the resolved group object instead of a boolean match flag. +- Added selective WordPress event pushes for high-signal bridge lifecycle events so MeshPress can forward them into OmniLog. +- Added bridge status visibility for WordPress event push configuration. + +## 0.4.3 - 2026-06-12 +- Fixed agent-link generation to pass the resolved group object instead of a boolean match flag. +- Added selective WordPress event pushes for high-signal bridge lifecycle events so MeshPress can forward them into OmniLog. +- Added bridge status visibility for WordPress event push configuration. + +## 0.4.2 - 2026-05-25 +- Added redacted MeshCentral config auditing for MySQL/MariaDB, framing, logging, backup, and maintenance posture. +- Added real MeshCentral `settings.maintenanceMode` toggling with config backup and service restart support. +- Replaced the data-only backup with a guarded full snapshot that includes MeshCentral files, config, manifest, checksum, and a MySQL/MariaDB dump when configured. +- Added a configurable database dump timeout (`MESHPRESS_DB_DUMP_TIMEOUT_SECONDS`) so backup failures produce clear diagnostics instead of hanging. + +## 0.4.1 - 2026-05-25 +- Resolved MeshCentral groups before generating agent links, so WordPress punctuation drift no longer breaks installer commands. +- Generated Linux install commands from the canonical group short id, including MeshCentral `@` characters. +- Returned the matched group record and group name with agent-link responses for clearer MeshPress output. +- Returned normalized group `short_id` values so agent-link generation can preserve MeshCentral punctuation across the full path. +- Added installer-shape diagnostics and an explicit warning when the resolved short Mesh ID looks too short. +- Added a curl fallback command while keeping the native MeshCentral wget installer command as the primary Linux path. + +## 0.4.0 - 2026-05-24 +- Resolved MeshCentral groups before generating agent links, so WordPress punctuation drift no longer breaks installer commands. +- Generated Linux install commands from the canonical group short id, including MeshCentral `@` characters. +- Returned the matched group record and group name with agent-link responses for clearer MeshPress output. + +## 0.3.9 - 2026-05-24 +- Replaced the bridge updater with a staged, verified, rollback-capable update flow that does not delete PM2 before the new copy is ready. +- Added catalog update checks and a runtime maintenance-mode toggle endpoint for MeshPress. +- Made group-id resolution tolerant of MeshCentral punctuation drift, and switched Linux agent commands to MeshCentral's native install-script pattern. + +## 0.3.8 - 2026-05-24 +- Group edits now verify the refreshed MeshCentral group record before returning success. +- Added extra group edit diagnostics when MeshCtrl accepts a command but the group does not actually change. +- Tried both `--desc` and `--description` edit forms for better MeshCtrl version compatibility. + +## 0.3.7 - 2026-05-24 +- Added guarded MeshCentral maintenance endpoints for version/update checks, data backups, and update triggering. +- Added agent uninstall diagnostics and richer copy-friendly bridge logging for lifecycle testing. +- Hardened the bridge installer/updater so running from the live `/opt` target refuses instead of deleting itself. + +## 0.3.6 - 2026-05-24 +- Rejected MeshCtrl failure text even when MeshCtrl exits with code 0, preventing false-success group edit responses. +- Added richer group edit candidate attempts and copyable JSON diagnostics for invalid group identifiers. +- Expanded agent link generation with Windows, Linux, macOS, Mesh Assistant, and Linux install command output. + +## 0.3.5 - 2026-05-24 +- Changed MeshCentral session links to prefer the working `viewmode-first + gotonode` URL pattern. +- Added short-node-id extraction for `gotonode` links so full `node//...` ids do not break direct console sessions. +- Added `/api/session-diagnostics/:nodeId` plus guarded device rename and move endpoints for manual MeshPress testing. + +## 0.3.4 - 2026-05-23 +- Added bridge runtime version reporting and a guarded manual update endpoint for MeshPress admin. +- Added richer MeshCentral session-link generation for Desktop, Console, and Files with multiple direct-link candidates. +- Added `console` and `shell` aliases for MeshCentral terminal sessions. + +## 0.3.3 - 2026-05-23 +- Added `/api/version`, `/api/update-now`, and `/api/session-links/:nodeId`. +- Added all-link candidate generation for MeshCentral session testing. +- Restarted bridge updates with `pm2 --update-env` so env/config changes are picked up. + +## 0.3.2 - 2026-05-23 +- Hardened bridge authentication for public-port exposure by requiring a configured bearer token on all endpoints. +- Added optional exact-IP allowlisting and failed-auth rate limiting. +- Moved WordPress fleet push authentication from query string to Authorization header. +- Added startup security warnings for all-interface binding without app-layer allowlisting. + +## 0.3.1 - 2026-05-23 +- Added bridge self-test, server-info, users, events, and device-info diagnostics for MeshPress. +- Added configurable MeshCentral direct-link style with `MESHPRESS_LINK_MODE`. +- Added guarded provisioning endpoints for group edit/remove, group user assignment, invite links, and simple device message/toast commands. +- Added cache clearing and richer status output for faster troubleshooting. + +## 0.3.0 - 2026-05-22 +- Added Phase 5.2 MeshCentral provisioning endpoints for Service Desk. +- Added authenticated agent/provisioning link generation for MeshCentral device groups. +- Added guarded device group creation through MeshCtrl `adddevicegroup`. +- Required `MESHPRESS_ALLOW_COMMANDS=1` before the bridge will create MeshCentral groups, keeping provisioning actions opt-in. +- Returned Windows x64, x86, ARM64, and interactive Windows agent links for linked MeshCentral groups. + + + +## Reconstructed History + +These entries were inferred from local distribution archives, module headers, and `.old` backups. They are intentionally conservative and may not capture the full intent of each change. + +### 0.2.0 - reconstructed +- Reconstructed from archive `meshpress-bridge-v0.2.0.zip` (2026-05-20). +- Header description at this version: GracePress MeshPress Bridge. Pushes MeshCentral fleet snapshots to WordPress MeshPress. +- Catalog/category marker: Hidden. + +### 0.1.1 - reconstructed +- Reconstructed from 2 file backup(s) in `tools/meshpress-bridge/.old`. +- Exact intent unknown; this confirms a recoverable historical version existed. + +### 0.1.0 - reconstructed +- Reconstructed from 1 file backup(s) in `tools/meshpress-bridge/.old`. +- Exact intent unknown; this confirms a recoverable historical version existed. + +### VERSION, - reconstructed +- Reconstructed from 2 file backup(s) in `tools/meshpress-bridge/.old`. +- Exact intent unknown; this confirms a recoverable historical version existed. + + diff --git a/bridge/meshpress-bridge/README.md b/bridge/meshpress-bridge/README.md new file mode 100644 index 0000000..c0a198c --- /dev/null +++ b/bridge/meshpress-bridge/README.md @@ -0,0 +1,184 @@ +# GracePress MeshPress Bridge + +Small local HTTP bridge between WordPress/MeshPress and MeshCentral. + +The bridge runs beside MeshCentral and uses MeshCentral's official `meshctrl` tool to read device groups and devices. WordPress talks to this bridge instead of trying to speak MeshCentral websocket protocol directly. + +## Endpoints + +- `GET /api/health` +- `GET /api/status` +- `GET /api/self-test` +- `GET /api/groups` +- `GET /api/users` +- `GET /api/events?limit=50` +- `GET /api/serverinfo` +- `GET /api/devices` +- `GET /api/device/:nodeId` +- `GET /api/fleet` +- `POST /api/push-now` +- `POST /api/cache/clear` +- `GET /api/link/desktop/:nodeId` +- `GET /api/link/terminal/:nodeId` +- `GET /api/link/files/:nodeId` +- `GET /api/link/:mode/:nodeId?link_mode=both` +- `GET /api/agent-links/:groupId` +- `POST /api/groups/create` +- `POST /api/groups/edit` +- `POST /api/groups/remove` +- `POST /api/groups/add-user` +- `POST /api/groups/remove-user` +- `POST /api/invite-link` +- `POST /api/device/message` +- `POST /api/device/toast` +- `POST /api/action` + +All endpoints require `Authorization: Bearer TOKEN`. If `MESHPRESS_BRIDGE_TOKEN` is empty, protected requests are rejected. + +`/api/action` is intentionally locked down for now. It returns a clear unsupported-command response until each broad remote-command workflow is whitelisted and tested. + +Provisioning and device-command endpoints require: + +```bash +MESHPRESS_ALLOW_COMMANDS="1" +``` + +Without that flag the bridge will read MeshCentral state but refuse provisioning actions. + +## Install + +```bash +cd /storage/meshcentral-workspace/bridge/meshpress-bridge +sudo ./install.sh +sudo nano /etc/gracepress/meshpress-bridge.env +pm2 restart gp-meshpress-bridge +``` + +## Required Config + +```bash +MESHPRESS_BRIDGE_PORT="3099" +MESHPRESS_BRIDGE_BIND="127.0.0.1" +MESHPRESS_BRIDGE_TOKEN="generated-secret" +MESHPRESS_ALLOWED_IPS="" +MESHPRESS_AUTH_WINDOW_SECONDS="900" +MESHPRESS_AUTH_MAX_FAILURES="30" +MESHCTRL_URL="wss://127.0.0.1" +PUBLIC_MESH_URL="https://mesh.yourdomain.com" +MESHCTRL_USER="admin" +MESHCTRL_PASS="" +MESHCTRL_DOMAIN="" +MESHCTRL_LOGIN_KEY_FILE="/etc/gracepress/meshcentral-login.key" +MESHCTRL_PATH="" +MESHCTRL_IGNORE_CERT="0" +MESHPRESS_LINK_HIDE="" +MESHPRESS_LINK_MODE="gotonode" +MESHPRESS_ALLOW_COMMANDS="0" +WP_DOMAIN="https://christp2p.com" +WP_API_KEY="same-token-used-in-meshpress" +MESHPRESS_PUSH_INTERVAL_SECONDS="3600" +MESHPRESS_PUSH_EVENTS="1" +``` + +If auto-detection cannot find `meshctrl`, set `MESHCTRL_PATH` to the full path of `meshctrl.js`. + +When `WP_DOMAIN` and `WP_API_KEY` are set, the bridge pushes fleet snapshots to WordPress at startup and then every hour. +When `MESHPRESS_PUSH_EVENTS` is enabled, selected bridge lifecycle events are posted to MeshPress so WordPress can record them through OmniLog. + +## Public Exposure Hardening + +Do not expose port `3099` to the public internet without firewall/VPN controls. Preferred options: + +1. Bind to localhost and proxy privately: + +```bash +MESHPRESS_BRIDGE_BIND="127.0.0.1" +``` + +2. Bind to a Tailscale address only: + +```bash +MESHPRESS_BRIDGE_BIND="100.x.y.z" +``` + +3. If the bridge must listen on all interfaces temporarily, restrict source IPs at both the firewall and app layer: + +```bash +MESHPRESS_BRIDGE_BIND="0.0.0.0" +MESHPRESS_ALLOWED_IPS="WORDPRESS_SERVER_IP,YOUR_ADMIN_IP" +``` + +The app-layer allowlist only accepts exact IP addresses. It is a backstop, not a replacement for firewall rules. + +## Diagnostics + +Use MeshPress > Diagnostics & Manual Actions first. It calls these bridge endpoints: + +```bash +curl -H "Authorization: Bearer TOKEN" http://127.0.0.1:3099/api/health +curl -H "Authorization: Bearer TOKEN" http://127.0.0.1:3099/api/version +curl -H "Authorization: Bearer TOKEN" http://127.0.0.1:3099/api/status +curl -H "Authorization: Bearer TOKEN" http://127.0.0.1:3099/api/self-test +curl -H "Authorization: Bearer TOKEN" http://127.0.0.1:3099/api/groups?refresh=1 +curl -H "Authorization: Bearer TOKEN" http://127.0.0.1:3099/api/devices?refresh=1 +``` + +`/api/self-test` verifies the bridge can find `meshctrl`, has an auth method, has a public MeshCentral URL, and can list device groups and devices. + +For direct session links, test both URL styles: + +```bash +curl -H "Authorization: Bearer TOKEN" "http://127.0.0.1:3099/api/session-links/NODEID" +curl -H "Authorization: Bearer TOKEN" "http://127.0.0.1:3099/api/link/console/NODEID?link_mode=all" +``` + +To trigger a catalog-managed bridge update from MeshPress admin or curl: + +```bash +curl -X POST -H "Authorization: Bearer TOKEN" http://127.0.0.1:3099/api/update-now +``` + +To test the first provisioning path, enable commands and create a temporary group: + +```bash +curl -X POST \ + -H "Authorization: Bearer TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"MeshPress Test Group","description":"Manual bridge validation"}' \ + http://127.0.0.1:3099/api/groups/create +``` + +## MeshCentral Auth + +The bridge supports either a MeshCentral login key file or a dedicated integration username/password. + +For username/password auth, set: + +```bash +MESHCTRL_USER="your-generated-integration-user" +MESHCTRL_PASS="your-generated-integration-password" +``` + +If `MESHCTRL_LOGIN_KEY_FILE` exists, the bridge will prefer it. If the file does not exist and `MESHCTRL_PASS` is set, it will use username/password auth. + +## MeshCentral Login Key Option + +On the MeshCentral server: + +```bash +cd /opt/meshcentral +sudo node node_modules/meshcentral --loginTokenKey > /etc/gracepress/meshcentral-login.key +sudo chmod 600 /etc/gracepress/meshcentral-login.key +``` + +MeshCentral must have login tokens enabled in `meshcentral-data/config.json`: + +```json +{ + "settings": { + "allowLoginToken": true + } +} +``` + +Restart MeshCentral after changing config. diff --git a/bridge/meshpress-bridge/install.sh b/bridge/meshpress-bridge/install.sh new file mode 100644 index 0000000..56f9876 --- /dev/null +++ b/bridge/meshpress-bridge/install.sh @@ -0,0 +1,117 @@ +#!/bin/bash +set -euo pipefail + +COMPONENT="meshpress-bridge" +TARGET_DIR="/opt/gracepress-meshpress-bridge" +ENV_DIR="/etc/gracepress" +ENV_FILE="$ENV_DIR/meshpress-bridge.env" +CRON_FILE="/etc/cron.d/gracepress-meshpress-bridge" +SERVICE_NAME="gp-meshpress-bridge" +SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERSION="$(grep -m 1 '^# Version:' "$SOURCE_DIR/meshpress-bridge" | sed -E 's/^# Version:[[:space:]]*//')" +SOURCE_REAL="$(realpath "$SOURCE_DIR")" +TARGET_REAL="$(realpath -m "$TARGET_DIR")" + +if [ "$(id -u)" -ne 0 ]; then + echo "Please run as root: sudo ./install.sh" + exit 1 +fi + +if [ "$SOURCE_REAL" = "$TARGET_REAL" ]; then + echo "Same-directory install detected; delegating to safe in-place updater." + exec "$TARGET_DIR/update.sh" +fi + +mkdir -p "$ENV_DIR" +touch "$ENV_FILE" +chmod 600 "$ENV_FILE" + +rand_token() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + else + date +%s%N | sha256sum | awk '{print $1}' + fi +} + +ensure_env() { + local key="$1" + local value="$2" + if ! grep -q "^${key}=" "$ENV_FILE"; then + echo "${key}=\"${value}\"" >> "$ENV_FILE" + fi +} + +ensure_env "MESHPRESS_BRIDGE_PORT" "${MESHPRESS_BRIDGE_PORT:-3099}" +ensure_env "MESHPRESS_BRIDGE_BIND" "${MESHPRESS_BRIDGE_BIND:-127.0.0.1}" +ensure_env "MESHPRESS_BRIDGE_TOKEN" "${MESHPRESS_BRIDGE_TOKEN:-$(rand_token)}" +ensure_env "MESHPRESS_ALLOWED_IPS" "${MESHPRESS_ALLOWED_IPS:-}" +ensure_env "MESHPRESS_AUTH_WINDOW_SECONDS" "${MESHPRESS_AUTH_WINDOW_SECONDS:-900}" +ensure_env "MESHPRESS_AUTH_MAX_FAILURES" "${MESHPRESS_AUTH_MAX_FAILURES:-30}" +ensure_env "MESHCTRL_URL" "${MESHCTRL_URL:-wss://127.0.0.1}" +ensure_env "PUBLIC_MESH_URL" "${PUBLIC_MESH_URL:-https://mesh.example.com}" +ensure_env "MESHCTRL_USER" "${MESHCTRL_USER:-admin}" +ensure_env "MESHCTRL_PASS" "${MESHCTRL_PASS:-}" +ensure_env "MESHCTRL_DOMAIN" "${MESHCTRL_DOMAIN:-}" +ensure_env "MESHCTRL_LOGIN_KEY_FILE" "${MESHCTRL_LOGIN_KEY_FILE:-/etc/gracepress/meshcentral-login.key}" +ensure_env "MESHCTRL_PATH" "${MESHCTRL_PATH:-}" +ensure_env "MESHCTRL_IGNORE_CERT" "${MESHCTRL_IGNORE_CERT:-0}" +ensure_env "MESHPRESS_CACHE_TTL_SECONDS" "${MESHPRESS_CACHE_TTL_SECONDS:-60}" +ensure_env "MESHPRESS_LINK_HIDE" "${MESHPRESS_LINK_HIDE:-}" +ensure_env "MESHPRESS_ALLOW_COMMANDS" "${MESHPRESS_ALLOW_COMMANDS:-0}" +ensure_env "MESHPRESS_ALLOW_MAINTENANCE" "${MESHPRESS_ALLOW_MAINTENANCE:-0}" +ensure_env "MESHCENTRAL_DIR" "${MESHCENTRAL_DIR:-}" +ensure_env "MESHCENTRAL_DATA_DIR" "${MESHCENTRAL_DATA_DIR:-}" +ensure_env "MESHCENTRAL_SERVICE_NAME" "${MESHCENTRAL_SERVICE_NAME:-meshcentral}" +ensure_env "MESHPRESS_BACKUP_DIR" "${MESHPRESS_BACKUP_DIR:-/var/backups/gracepress-meshcentral}" +ensure_env "WP_DOMAIN" "${WP_DOMAIN:-}" +ensure_env "WP_API_KEY" "${WP_API_KEY:-}" +ensure_env "MESHPRESS_PUSH_INTERVAL_SECONDS" "${MESHPRESS_PUSH_INTERVAL_SECONDS:-3600}" +ensure_env "MESHPRESS_PUSH_EVENTS" "${MESHPRESS_PUSH_EVENTS:-1}" +ensure_env "AUTO_UPDATE" "${AUTO_UPDATE:-1}" + +apt-get update -y >/dev/null +apt-get install -y curl ca-certificates >/dev/null +curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null +apt-get install -y nodejs >/dev/null +npm install -g pm2 >/dev/null + +pm2 install pm2-logrotate >/dev/null 2>&1 || true +pm2 set pm2-logrotate:max_size 25M >/dev/null 2>&1 || true +pm2 set pm2-logrotate:retain 5 >/dev/null 2>&1 || true + +STAGE_DIR="${TARGET_DIR}.installing.$$" +rm -rf "$STAGE_DIR" +mkdir -p "$STAGE_DIR" +cp -a "$SOURCE_DIR"/. "$STAGE_DIR"/ +echo "$VERSION" > "$STAGE_DIR/VERSION" +cd "$STAGE_DIR" +npm install --omit=dev >/dev/null +node --check server.js >/dev/null +chmod +x "$STAGE_DIR/update.sh" "$STAGE_DIR/meshpress-bridge" + +pm2 delete "$SERVICE_NAME" >/dev/null 2>&1 || true +if [ -d "$TARGET_DIR" ]; then + BACKUP_DIR="${TARGET_DIR}.old.$(date +%Y%m%d%H%M%S)" + mv "$TARGET_DIR" "$BACKUP_DIR" + echo "Previous install moved to $BACKUP_DIR" +fi +mv "$STAGE_DIR" "$TARGET_DIR" +cd "$TARGET_DIR" + +pm2 start server.js --name "$SERVICE_NAME" --time --max-memory-restart 512M >/dev/null +pm2 save >/dev/null +pm2 startup | grep "sudo" | bash >/dev/null 2>&1 || true + +cat > "$CRON_FILE" </var/log/gracepress-meshpress-bridge-update.log 2>&1 +EOF +chmod 644 "$CRON_FILE" + +echo "GracePress MeshPress Bridge v$VERSION installed." +echo "Config: $ENV_FILE" +echo "Token: $(grep '^MESHPRESS_BRIDGE_TOKEN=' "$ENV_FILE" | cut -d= -f2- | sed 's/^\"//;s/\"$//')" +echo "Next: edit $ENV_FILE and set MESHCTRL_URL, PUBLIC_MESH_URL, MESHCTRL_USER, MESHCTRL_PASS or MESHCTRL_LOGIN_KEY_FILE, MESHCTRL_PATH, WP_DOMAIN, and WP_API_KEY." +echo "Then restart: pm2 restart $SERVICE_NAME --update-env" diff --git a/bridge/meshpress-bridge/meshpress-bridge b/bridge/meshpress-bridge/meshpress-bridge new file mode 100644 index 0000000..2019a83 --- /dev/null +++ b/bridge/meshpress-bridge/meshpress-bridge @@ -0,0 +1,7 @@ +#!/bin/bash +# Description: GracePress MeshPress Bridge. Pushes MeshCentral fleet snapshots to WordPress MeshPress. +# Category: Hidden +# Version: 0.4.3 + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$DIR/server.js" "$@" diff --git a/bridge/meshpress-bridge/package.json b/bridge/meshpress-bridge/package.json new file mode 100644 index 0000000..26d4ae0 --- /dev/null +++ b/bridge/meshpress-bridge/package.json @@ -0,0 +1,15 @@ +{ + "name": "gracepress-meshpress-bridge", + "version": "0.4.3", + "private": true, + "description": "GracePress bridge service for MeshCentral fleet sync and device links", + "main": "server.js", + "scripts": { + "start": "node server.js", + "check": "node --check server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.3" + } +} diff --git a/bridge/meshpress-bridge/server.js b/bridge/meshpress-bridge/server.js new file mode 100644 index 0000000..f79a25d --- /dev/null +++ b/bridge/meshpress-bridge/server.js @@ -0,0 +1,2063 @@ +const express = require('express'); +const cors = require('cors'); +const fs = require('fs'); +const path = require('path'); +const { execFile, spawn } = require('child_process'); +const crypto = require('crypto'); + +const VERSION = '0.4.3'; +const ENV_FILE = '/etc/gracepress/meshpress-bridge.env'; + +function loadEnv(file) { + if (!fs.existsSync(file)) return; + for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) continue; + const key = match[1]; + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = value; + } +} + +loadEnv(ENV_FILE); +loadEnv(path.join(__dirname, '.env')); + +const config = { + port: Number(process.env.MESHPRESS_BRIDGE_PORT || 3099), + bind: process.env.MESHPRESS_BRIDGE_BIND || '127.0.0.1', + token: process.env.MESHPRESS_BRIDGE_TOKEN || '', + meshUrl: process.env.MESHCTRL_URL || process.env.MESH_URL || 'wss://127.0.0.1', + publicMeshUrl: (process.env.PUBLIC_MESH_URL || process.env.MESH_PUBLIC_URL || '').replace(/\/+$/, ''), + user: process.env.MESHCTRL_USER || process.env.MESH_USER || 'admin', + pass: process.env.MESHCTRL_PASS || process.env.MESH_PASS || '', + domain: process.env.MESHCTRL_DOMAIN || process.env.MESH_DOMAIN || '', + loginKeyFile: process.env.MESHCTRL_LOGIN_KEY_FILE || process.env.MESH_LOGIN_KEY_FILE || '/etc/gracepress/meshcentral-login.key', + meshctrlPath: process.env.MESHCTRL_PATH || '', + ignoreCert: process.env.MESHCTRL_IGNORE_CERT === '1', + cacheTtl: Number(process.env.MESHPRESS_CACHE_TTL_SECONDS || 60), + timeoutMs: Number(process.env.MESHPRESS_MESHCTRL_TIMEOUT_MS || 20000), + linkHide: process.env.MESHPRESS_LINK_HIDE || '', + linkMode: process.env.MESHPRESS_LINK_MODE || 'gotonode', + allowedIps: String(process.env.MESHPRESS_ALLOWED_IPS || '').split(',').map(v => v.trim()).filter(Boolean), + authWindowMs: Number(process.env.MESHPRESS_AUTH_WINDOW_SECONDS || 900) * 1000, + authMaxFailures: Number(process.env.MESHPRESS_AUTH_MAX_FAILURES || 30), + corsOrigin: process.env.MESHPRESS_CORS_ORIGIN || '', + allowCommands: process.env.MESHPRESS_ALLOW_COMMANDS === '1', + allowMaintenance: process.env.MESHPRESS_ALLOW_MAINTENANCE === '1', + meshCentralDir: process.env.MESHCENTRAL_DIR || process.env.MESH_CENTRAL_DIR || '', + meshCentralDataDir: process.env.MESHCENTRAL_DATA_DIR || process.env.MESH_CENTRAL_DATA_DIR || '', + meshCentralConfigPath: process.env.MESHCENTRAL_CONFIG_PATH || process.env.MESH_CENTRAL_CONFIG_PATH || '', + meshCentralServiceName: process.env.MESHCENTRAL_SERVICE_NAME || process.env.MESH_CENTRAL_SERVICE_NAME || 'meshcentral', + meshCentralBackupDir: process.env.MESHPRESS_BACKUP_DIR || process.env.MESHCENTRAL_BACKUP_DIR || '/var/backups/gracepress-meshcentral', + maintenanceLogFile: process.env.MESHPRESS_MAINTENANCE_LOG || '/var/log/gracepress-meshcentral-maintenance.log', + mysqlDumpPath: process.env.MESHPRESS_MYSQLDUMP_PATH || process.env.MYSQLDUMP_PATH || 'mysqldump', + mysqlPath: process.env.MESHPRESS_MYSQL_PATH || process.env.MYSQL_PATH || 'mysql', + dbDumpTimeoutMs: Number(process.env.MESHPRESS_DB_DUMP_TIMEOUT_SECONDS || 600) * 1000, + backupStopService: process.env.MESHPRESS_BACKUP_STOP_SERVICE === '1', + wpDomain: (process.env.WP_DOMAIN || process.env.MESHPRESS_WP_DOMAIN || '').replace(/\/+$/, ''), + wpApiKey: process.env.WP_API_KEY || process.env.MESHPRESS_WP_API_KEY || process.env.MESHPRESS_BRIDGE_TOKEN || '', + pushIntervalSeconds: Number(process.env.MESHPRESS_PUSH_INTERVAL_SECONDS || 3600), + pushEvents: String(process.env.MESHPRESS_PUSH_EVENTS || '1') !== '0', + catalogUrl: process.env.GRACEPRESS_CATALOG_URL || 'https://christit.com/gracepress/api.php?endpoint=manifest&slug=meshpress-bridge' +}; + +const app = express(); +app.disable('x-powered-by'); +app.use(express.json({ limit: '1mb' })); +if (config.corsOrigin) { + app.use(cors({ origin: config.corsOrigin })); +} + +const cache = new Map(); +const authFailures = new Map(); + +function bridgeLog(event, data = {}) { + const payload = Object.assign({ + event, + ts: new Date().toISOString() + }, data); + console.log(`[meshpress-bridge] ${JSON.stringify(payload)}`); + pushBridgeEvent(event, data).catch(error => console.error(`[meshpress-bridge] event push failed: ${error.message}`)); +} + +const WORDPRESS_EVENT_ALLOWLIST = new Set([ + 'bridge_update_check', + 'bridge_update_check_failed', + 'meshcentral_maintenance_status', + 'meshcentral_config_audit', + 'meshcentral_maintenance_toggle', + 'meshcentral_maintenance_toggle_failed', + 'meshcentral_backup_started', + 'meshcentral_backup_db_dumped', + 'meshcentral_backup_finished', + 'meshcentral_backup_failed', + 'meshcentral_update_started', + 'meshcentral_update_failed', + 'maintenance_toggle', + 'group_resolve', + 'group_resolve_failed', + 'group_edit_verified_success', + 'group_edit_failed', + 'device_uninstall_success', + 'device_uninstall_failed', + 'agent_links', + 'fleet_push_success', + 'fleet_push_failed', + 'enrollment_wait', + 'enrollment_seen' +]); + +function eventSeverity(event) { + if (/failed|failure|error/i.test(event)) return 'ERROR'; + if (/success|finished|seen/i.test(event)) return 'SUCCESS'; + return 'INFO'; +} + +function redactEventData(value) { + if (Array.isArray(value)) return value.map(redactEventData).slice(0, 50); + if (!value || typeof value !== 'object') return value; + const out = {}; + for (const [key, item] of Object.entries(value)) { + if (/pass|password|token|secret|key|authorization|cookie/i.test(key)) { + out[key] = item ? '[redacted]' : item; + } else { + out[key] = redactEventData(item); + } + } + return out; +} + +async function pushBridgeEvent(event, data = {}) { + if (!config.pushEvents || !config.wpDomain || !config.wpApiKey || !WORDPRESS_EVENT_ALLOWLIST.has(event)) return; + const response = await fetch(`${config.wpDomain}/wp-json/gracepress/v1/meshpress/event`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.wpApiKey}` }, + body: JSON.stringify({ + event, + severity: eventSeverity(event), + bridge_version: VERSION, + data: redactEventData(data), + created_at: new Date().toISOString() + }) + }); + if (!response.ok) throw new Error(`WordPress event endpoint returned HTTP ${response.status}`); +} + +function publicErrorPayload(error, data = {}) { + return Object.assign({ + success: false, + error: error && error.message ? error.message : String(error || 'Unknown bridge error.') + }, data); +} + +function candidateMeshctrlPaths() { + const candidates = []; + if (config.meshctrlPath) candidates.push(config.meshctrlPath); + candidates.push( + '/opt/meshcentral/node_modules/meshcentral/meshctrl.js', + '/opt/meshcentral/node_modules/meshcentral/meshctrl', + '/usr/local/lib/node_modules/meshcentral/meshctrl.js', + '/usr/local/lib/node_modules/meshcentral/meshctrl' + ); + for (const home of ['/root', '/home/jbird']) { + candidates.push( + `${home}/meshcentral/node_modules/meshcentral/meshctrl.js`, + `${home}/meshcentral/node_modules/meshcentral/meshctrl` + ); + } + return candidates; +} + +function findMeshctrl() { + for (const candidate of candidateMeshctrlPaths()) { + if (candidate && fs.existsSync(candidate)) return candidate; + } + return ''; +} + +function remoteIp(req) { + return String(req.socket.remoteAddress || '').replace(/^::ffff:/, ''); +} + +function isAllowedIp(req) { + if (!config.allowedIps.length) return true; + const ip = remoteIp(req); + return config.allowedIps.includes(ip) || config.allowedIps.includes(req.socket.remoteAddress || ''); +} + +function constantTimeEqual(a, b) { + const left = Buffer.from(String(a || '')); + const right = Buffer.from(String(b || '')); + if (left.length !== right.length) return false; + return crypto.timingSafeEqual(left, right); +} + +function authLimited(ip) { + const now = Date.now(); + const windowMs = Math.max(config.authWindowMs, 60000); + const hit = authFailures.get(ip) || { count: 0, start: now }; + if ((now - hit.start) > windowMs) { + authFailures.set(ip, { count: 0, start: now }); + return false; + } + return hit.count >= config.authMaxFailures; +} + +function recordAuthFailure(ip) { + const now = Date.now(); + const windowMs = Math.max(config.authWindowMs, 60000); + const hit = authFailures.get(ip) || { count: 0, start: now }; + if ((now - hit.start) > windowMs) { + authFailures.set(ip, { count: 1, start: now }); + return; + } + hit.count += 1; + authFailures.set(ip, hit); +} + +function clearAuthFailures(ip) { + authFailures.delete(ip); +} + +function auth(req, res, next) { + const ip = remoteIp(req); + if (!isAllowedIp(req)) { + recordAuthFailure(ip); + return res.status(403).json({ success: false, error: 'Forbidden' }); + } + if (authLimited(ip)) { + return res.status(429).json({ success: false, error: 'Too many failed authorization attempts.' }); + } + if (!config.token) { + recordAuthFailure(ip); + return res.status(503).json({ success: false, error: 'Bridge token is not configured. Set MESHPRESS_BRIDGE_TOKEN before exposing this service.' }); + } + const header = req.get('authorization') || ''; + const bearer = header.startsWith('Bearer ') ? header.slice(7) : ''; + const supplied = bearer || req.get('x-meshpress-token') || ''; + if (supplied && constantTimeEqual(supplied, config.token)) { + clearAuthFailures(ip); + return next(); + } + recordAuthFailure(ip); + return res.status(401).json({ success: false, error: 'Unauthorized' }); +} + +function cacheGet(key) { + const hit = cache.get(key); + if (!hit) return null; + if ((Date.now() - hit.time) > (config.cacheTtl * 1000)) { + cache.delete(key); + return null; + } + return hit.value; +} + +function cacheSet(key, value) { + cache.set(key, { time: Date.now(), value }); + return value; +} + +function meshctrlArgs(command, extra = []) { + const args = [command, '--json', '--url', config.meshUrl, '--loginuser', config.user]; + if (config.loginKeyFile && fs.existsSync(config.loginKeyFile)) { + args.push('--loginkeyfile', config.loginKeyFile); + } else if (config.pass) { + args.push('--loginpass', config.pass); + } + if (config.domain) args.push('--domain', config.domain); + if (config.ignoreCert) args.push('--ignorecert'); + return args.concat(extra); +} + +function parseMeshctrlJson(raw) { + const text = String(raw || '').trim(); + if (!text) return []; + try { + return JSON.parse(text); + } catch (_) {} + + const firstArray = text.indexOf('['); + const lastArray = text.lastIndexOf(']'); + if (firstArray >= 0 && lastArray > firstArray) { + try { + return JSON.parse(text.slice(firstArray, lastArray + 1)); + } catch (_) {} + } + + const firstObject = text.indexOf('{'); + const lastObject = text.lastIndexOf('}'); + if (firstObject >= 0 && lastObject > firstObject) { + const block = text.slice(firstObject, lastObject + 1); + try { + return JSON.parse(block); + } catch (_) {} + } + + const parsed = []; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim().replace(/,$/, ''); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) continue; + try { + parsed.push(JSON.parse(trimmed)); + } catch (_) {} + } + if (parsed.length) return parsed.flat(); + throw new Error('MeshCtrl did not return parseable JSON.'); +} + +function runMeshctrl(command, extra = []) { + return new Promise((resolve, reject) => { + const meshctrl = findMeshctrl(); + if (!meshctrl) { + reject(new Error('meshctrl was not found. Set MESHCTRL_PATH in /etc/gracepress/meshpress-bridge.env.')); + return; + } + if ((!config.loginKeyFile || !fs.existsSync(config.loginKeyFile)) && !config.pass) { + reject(new Error('No MeshCentral authentication method is configured. Set MESHCTRL_PASS or provide MESHCTRL_LOGIN_KEY_FILE.')); + return; + } + const args = [meshctrl].concat(meshctrlArgs(command, extra)); + execFile('node', args, { timeout: config.timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { + const message = stderr || stdout || error.message; + reject(new Error(message.trim())); + return; + } + try { + resolve(parseMeshctrlJson(stdout)); + } catch (parseError) { + reject(parseError); + } + }); + }); +} + +function runMeshctrlText(command, extra = []) { + return new Promise((resolve, reject) => { + const meshctrl = findMeshctrl(); + if (!meshctrl) { + reject(new Error('meshctrl was not found. Set MESHCTRL_PATH in /etc/gracepress/meshpress-bridge.env.')); + return; + } + if ((!config.loginKeyFile || !fs.existsSync(config.loginKeyFile)) && !config.pass) { + reject(new Error('No MeshCentral authentication method is configured. Set MESHCTRL_PASS or provide MESHCTRL_LOGIN_KEY_FILE.')); + return; + } + const args = [meshctrl].concat(meshctrlArgs(command, extra).filter(arg => arg !== '--json')); + execFile('node', args, { timeout: config.timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { + const output = `${stdout || ''}${stderr ? `\n${stderr}` : ''}`.trim(); + if (error) { + reject(new Error(output || error.message)); + return; + } + resolve(output || 'ok'); + }); + }); +} + +function runCommand(command, args = [], options = {}) { + return new Promise((resolve, reject) => { + const child = execFile(command, args, { + timeout: options.timeout || 120000, + maxBuffer: options.maxBuffer || 10 * 1024 * 1024, + cwd: options.cwd || undefined, + env: Object.assign({}, process.env, options.env || {}) + }, (error, stdout, stderr) => { + const output = `${stdout || ''}${stderr ? `\n${stderr}` : ''}`.trim(); + if (error) { + reject(new Error(output || error.message)); + return; + } + resolve(output || 'ok'); + }); + if (options.stdin) { + child.stdin.end(options.stdin); + } + }); +} + +function appendMaintenanceLog(event, data = {}) { + const line = JSON.stringify(Object.assign({ ts: new Date().toISOString(), event }, data)) + '\n'; + fs.mkdirSync(path.dirname(config.maintenanceLogFile), { recursive: true }); + fs.appendFileSync(config.maintenanceLogFile, line, { mode: 0o600 }); + bridgeLog(event, data); +} + +function isMeshctrlFailureText(output) { + const text = String(output || '').toLowerCase(); + if (!text) return false; + return /invalid\s+(group|mesh|node|device)\s+identifier|not\s+found|unknown\s+command|failed|failure|error\s+\d+|unable\s+to|access\s+denied|permission\s+denied/.test(text); +} + +function asArray(value) { + if (Array.isArray(value)) return value; + if (!value) return []; + if (typeof value === 'object') return Object.values(value); + return []; +} + +function normalizeGroup(group) { + const id = group._id || group.id || group.meshid || ''; + return { + id, + short_id: shortNodeId(id), + name: group.name || group.meshname || group.desc || 'Unnamed Group', + description: group.desc || group.description || '', + raw: group + }; +} + +function normalizeDevice(device, groupMap = new Map()) { + const meshId = device.meshid || device.mesh || device.groupid || ''; + return { + id: device._id || device.id || device.nodeid || '', + name: device.name || device.rname || device.host || device.hostname || 'Unnamed Device', + osName: device.rname || '', + groupId: meshId, + groupName: groupMap.get(meshId) || device.meshname || device.groupname || '', + online: Number(device.conn || device.pwr || 0) > 0, + connection: device.conn || 0, + lastConnect: device.lastconnect || device.lastConnect || null, + ip: device.ip || device.paddr || device.host || '', + raw: device + }; +} + +function meshEntityAliases(entity) { + const raw = entity && entity.raw && typeof entity.raw === 'object' ? entity.raw : {}; + return [ + entity && entity.id, + entity && entity.name, + raw._id, + raw.id, + raw.meshid, + raw.meshId, + raw.nodeid, + raw.nodeId, + raw.name, + raw.meshname, + raw.desc, + raw.domain, + raw._id ? shortNodeId(raw._id) : '', + entity && entity.id ? shortNodeId(entity.id) : '' + ].filter(value => value !== undefined && value !== null).map(value => String(value).trim()).filter(Boolean); +} + +function groupMatchesCandidate(group, candidates = []) { + const wanted = new Set(candidates.filter(Boolean).map(value => String(value).trim())); + if (!wanted.size) return false; + const looseWanted = new Set([...wanted].map(looseMeshKey).filter(Boolean)); + return meshEntityAliases(group).some(alias => wanted.has(alias) || looseWanted.has(looseMeshKey(alias))); +} + +function looseMeshKey(value) { + return String(value || '').replace(/^mesh\/\//, '').replace(/[^A-Za-z0-9]/g, '').toLowerCase(); +} + +function verifyGroupEdit(group, requestedName, requestedDescription) { + const mismatches = []; + if (!group) { + return { ok: false, mismatches: ['Group was not found after edit attempt.'] }; + } + if (requestedName && String(group.name || '') !== requestedName) { + mismatches.push(`Name is still "${group.name || ''}" instead of "${requestedName}".`); + } + if (requestedDescription && String(group.description || '') !== requestedDescription) { + mismatches.push(`Description is still "${group.description || ''}" instead of requested description.`); + } + return { ok: mismatches.length === 0, mismatches }; +} + +function normalizeUser(user) { + return { + id: user._id || user.id || user.userid || user.name || '', + name: user.name || user.username || user.id || 'Unnamed User', + email: user.email || '', + raw: user + }; +} + +function normalizeEvent(event) { + return { + time: event.time || event.date || event.msgid || '', + action: event.action || event.event || '', + message: event.msg || event.message || event.desc || '', + nodeId: event.nodeid || event.node || '', + userId: event.userid || event.user || '', + raw: event + }; +} + +async function getGroups(force = false) { + const key = 'groups'; + if (!force) { + const hit = cacheGet(key); + if (hit) return hit; + } + const raw = await runMeshctrl('listdevicegroups'); + return cacheSet(key, asArray(raw).map(normalizeGroup).filter(g => g.id)); +} + +async function getDevices(force = false) { + const key = 'devices'; + if (!force) { + const hit = cacheGet(key); + if (hit) return hit; + } + const groups = await getGroups(force).catch(() => []); + const groupMap = new Map(groups.map(g => [g.id, g.name])); + const raw = await runMeshctrl('listdevices'); + return cacheSet(key, asArray(raw).map(d => normalizeDevice(d, groupMap)).filter(d => d.id)); +} + +async function getUsers(force = false) { + const key = 'users'; + if (!force) { + const hit = cacheGet(key); + if (hit) return hit; + } + const raw = await runMeshctrl('listusers'); + return cacheSet(key, asArray(raw).map(normalizeUser).filter(u => u.id || u.name)); +} + +async function getEvents(force = false) { + const key = 'events'; + if (!force) { + const hit = cacheGet(key); + if (hit) return hit; + } + const raw = await runMeshctrl('listevents'); + return cacheSet(key, asArray(raw).map(normalizeEvent)); +} + +async function resolveGroupId(input) { + const requested = String(input || '').trim(); + const groups = await getGroups(true).catch(() => []); + const requestedShort = shortNodeId(requested); + const wanted = new Set([requested, requestedShort].filter(Boolean)); + const looseWanted = new Set([requested, requestedShort].map(looseMeshKey).filter(Boolean)); + const group = groups.find(item => { + const aliases = meshEntityAliases(item); + return aliases.some(alias => wanted.has(alias) || looseWanted.has(looseMeshKey(alias))); + }) || null; + return { + requested, + resolved_id: group ? group.id : requested, + resolved_short_id: shortNodeId(group ? group.id : requested), + group, + matched: Boolean(group), + known_groups: groups.map(item => ({ id: item.id, short_id: shortNodeId(item.id), name: item.name, description: item.description })) + }; +} + +function groupIdCandidates(resolution) { + const candidates = []; + const add = value => { + const text = String(value || '').trim(); + if (text && !candidates.includes(text)) candidates.push(text); + }; + add(resolution.resolved_id); + add(resolution.resolved_short_id); + if (resolution.group) { + for (const alias of meshEntityAliases(resolution.group)) add(alias); + add(resolution.group.name); + } + add(resolution.requested); + add(shortNodeId(resolution.requested)); + return candidates; +} + +async function resolveDeviceId(input) { + const requested = String(input || '').trim(); + const devices = await getDevices(true).catch(() => []); + const requestedShort = shortNodeId(requested); + const device = devices.find(item => { + const aliases = meshEntityAliases(item); + aliases.push(shortNodeId(item.id)); + return aliases.includes(requested) || aliases.includes(requestedShort); + }) || null; + return { + requested, + resolved_id: device ? device.id : requested, + device, + known_devices: devices.map(item => ({ id: item.id, short_id: shortNodeId(item.id), name: item.name, group: item.groupName, online: item.online })) + }; +} + +function bridgeStatus() { + const meshctrl = findMeshctrl(); + const authMethod = config.loginKeyFile && fs.existsSync(config.loginKeyFile) ? 'login_key_file' : (config.pass ? 'username_password' : 'none'); + return { + success: true, + version: VERSION, + bind: config.bind, + port: config.port, + meshctrl_found: Boolean(meshctrl), + meshctrl_path: meshctrl, + mesh_url: config.meshUrl, + public_mesh_url: config.publicMeshUrl, + public_mesh_url_configured: Boolean(config.publicMeshUrl), + login_key_file: config.loginKeyFile, + login_key_file_exists: config.loginKeyFile ? fs.existsSync(config.loginKeyFile) : false, + password_configured: Boolean(config.pass), + auth_method: authMethod, + domain: config.domain, + ignore_cert: config.ignoreCert, + cache_ttl_seconds: config.cacheTtl, + commands_enabled: config.allowCommands, + maintenance_enabled: config.allowMaintenance, + meshcentral_dir: config.meshCentralDir, + meshcentral_data_dir: config.meshCentralDataDir, + meshcentral_config_path: meshCentralConfigPath(), + meshcentral_service_name: config.meshCentralServiceName, + meshcentral_backup_dir: config.meshCentralBackupDir, + db_dump_timeout_seconds: Math.round(config.dbDumpTimeoutMs / 1000), + backup_stop_service_default: config.backupStopService, + link_mode: config.linkMode, + allowed_ips_configured: config.allowedIps.length > 0, + allowed_ip_count: config.allowedIps.length, + auth_required: Boolean(config.token), + auth_window_seconds: Math.round(config.authWindowMs / 1000), + auth_max_failures: config.authMaxFailures, + wp_push_configured: Boolean(config.wpDomain && config.wpApiKey), + wp_event_push_enabled: config.pushEvents, + wp_domain: config.wpDomain || '' + }; +} + +function inferredMeshCentralPackageDir() { + const meshctrl = findMeshctrl(); + if (!meshctrl) return ''; + const normalized = meshctrl.replace(/\\/g, '/'); + const marker = '/node_modules/meshcentral/'; + const idx = normalized.indexOf(marker); + if (idx >= 0) return normalized.slice(0, idx + marker.length + 'meshcentral'.length); + if (normalized.endsWith('/meshctrl.js') || normalized.endsWith('/meshctrl')) return path.dirname(normalized); + return ''; +} + +function inferredMeshCentralProjectDir() { + if (config.meshCentralDir) return config.meshCentralDir; + const pkg = inferredMeshCentralPackageDir(); + if (!pkg) return ''; + const marker = '/node_modules/meshcentral'; + const idx = pkg.replace(/\\/g, '/').indexOf(marker); + return idx >= 0 ? pkg.slice(0, idx) : path.dirname(pkg); +} + +function readJsonFile(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (_) { + return null; + } +} + +function meshCentralConfigPath() { + if (config.meshCentralConfigPath) return config.meshCentralConfigPath; + if (config.meshCentralDataDir) return path.join(config.meshCentralDataDir, 'config.json'); + const projectDir = inferredMeshCentralProjectDir(); + if (projectDir) return path.join(projectDir, 'meshcentral-data', 'config.json'); + return ''; +} + +function redactSecrets(value) { + if (Array.isArray(value)) return value.map(redactSecrets); + if (!value || typeof value !== 'object') return value; + const out = {}; + for (const [key, item] of Object.entries(value)) { + if (/pass|password|token|secret|key|session/i.test(key)) { + out[key] = item ? '[redacted]' : item; + } else { + out[key] = redactSecrets(item); + } + } + return out; +} + +function loadMeshCentralConfig() { + const file = meshCentralConfigPath(); + if (!file || !fs.existsSync(file)) { + return { path: file, exists: false, config: null, error: file ? 'Config file was not found.' : 'Config path is unknown.' }; + } + try { + return { path: file, exists: true, config: JSON.parse(fs.readFileSync(file, 'utf8')), error: '' }; + } catch (error) { + return { path: file, exists: true, config: null, error: error.message }; + } +} + +function meshCentralDataDirFromConfigPath(file) { + return file ? path.dirname(file) : config.meshCentralDataDir; +} + +function meshCentralDbConfig(mcConfig) { + const settings = mcConfig && mcConfig.settings ? mcConfig.settings : {}; + const db = settings.MySQL || settings.mysql || settings.MariaDB || settings.mariadb || null; + if (!db || typeof db !== 'object') return { type: 'embedded', configured: false }; + return { + type: settings.MySQL || settings.mysql ? 'mysql' : 'mariadb', + configured: true, + host: db.host || 'localhost', + port: Number(db.port || 3306), + user: db.user || db.username || '', + password: db.password || db.pass || '', + database: db.database || db.name || '' + }; +} + +function meshCentralConfigAudit(mcConfig) { + const settings = mcConfig && mcConfig.settings ? mcConfig.settings : {}; + const domains = mcConfig && mcConfig.domains ? mcConfig.domains : {}; + const recommendations = []; + const add = (severity, key, message, suggested) => recommendations.push({ severity, key, message, suggested }); + + if (settings.allowFraming === true && !settings.allowedFramingOrigins) { + add('high', 'settings.allowedFramingOrigins', 'Framing is enabled globally. Restrict it to the WordPress origin used by MeshPress.', ['https://christp2p.com']); + } + if (!settings.dbRecordsEncryptKey) { + add('medium', 'settings.dbRecordsEncryptKey', 'MeshCentral database records are not explicitly configured for at-rest encryption.', 'Generate a long random value and keep it backed up.'); + } + if (!settings.authLog) { + add('medium', 'settings.authLog', 'Authentication events are not explicitly written to a dedicated log file.', '/var/log/meshcentral-auth.log'); + } + if (!settings.invalidLogin || typeof settings.invalidLogin !== 'object') { + add('medium', 'settings.invalidLogin', 'Add invalid-login throttling so repeated login attempts are slowed down.', { time: 10, count: 10, coolofftime: 10 }); + } + if (settings.selfUpdate !== false) { + add('low', 'settings.selfUpdate', 'Consider disabling MeshCentral self-update if MeshPress/bridge will own maintenance workflow.', false); + } + if (!settings.meshErrorLogPath) { + add('low', 'settings.meshErrorLogPath', 'Set a dedicated MeshCentral error log path for easier copy/paste diagnostics.', '/var/log/meshcentral-error.log'); + } + if (!settings.backupPath) { + add('low', 'settings.backupPath', 'Set MeshCentral backupPath even though MeshPress will create full snapshots.', '/var/backups/meshcentral'); + } + if (!settings.sessionKey || String(settings.sessionKey).length < 24) { + add('medium', 'settings.sessionKey', 'Use a long random sessionKey and back it up. Changing it logs users out.', 'long random string'); + } + if (settings.maintenanceMode === true) { + add('info', 'settings.maintenanceMode', 'MeshCentral maintenance mode is currently enabled.', false); + } + if (settings.ignoreAgentHashCheck === true) { + add('info', 'settings.ignoreAgentHashCheck', 'Agent hash checking is intentionally ignored for this installation. MeshPress will not change it.', true); + } + + return { + maintenance_mode: settings.maintenanceMode === true, + allow_framing: settings.allowFraming === true, + allowed_framing_origins: settings.allowedFramingOrigins || [], + mysql: meshCentralDbConfig(mcConfig), + domain_count: Object.keys(domains).length, + recommendations + }; +} + +function writeMeshCentralConfig(mcPath, mcConfig) { + const backup = `${mcPath}.meshpress-old-${Date.now()}`; + fs.copyFileSync(mcPath, backup); + fs.chmodSync(backup, 0o600); + fs.writeFileSync(mcPath, JSON.stringify(mcConfig, null, 2) + '\n', { mode: 0o600 }); + return backup; +} + +async function serviceAction(action) { + const serviceName = config.meshCentralServiceName; + const pm2Args = action === 'restart' ? ['restart', serviceName, '--update-env'] : [action, serviceName]; + const pm2Action = async () => runCommand('pm2', pm2Args, { timeout: 120000 }); + const systemctlAction = async () => runCommand('systemctl', [action, serviceName], { timeout: 120000 }); + try { + return await pm2Action(); + } catch (pm2Error) { + try { + return await systemctlAction(); + } catch (systemctlError) { + throw new Error(`pm2 ${action} failed: ${pm2Error.message}; systemctl ${action} failed: ${systemctlError.message}`); + } + } +} + +function sha256File(file) { + const hash = crypto.createHash('sha256'); + hash.update(fs.readFileSync(file)); + return hash.digest('hex'); +} + +function dumpDatabaseToFile(db, targetFile) { + return new Promise((resolve, reject) => { + const dumpArgs = [ + '--single-transaction', + '--routines', + '--triggers', + '--events', + '--hex-blob', + '--host', db.host, + '--port', String(db.port), + '--user', db.user, + `--password=${db.password}`, + db.database + ]; + const out = fs.openSync(targetFile, 'w', 0o600); + const child = spawn(config.mysqlDumpPath, dumpArgs, { stdio: ['ignore', out, 'pipe'] }); + const timer = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error(`mysqldump exceeded ${Math.round(config.dbDumpTimeoutMs / 1000)} seconds`)); + }, config.dbDumpTimeoutMs); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk.toString(); }); + child.on('error', error => { + clearTimeout(timer); + fs.closeSync(out); + reject(error); + }); + child.on('close', code => { + clearTimeout(timer); + fs.closeSync(out); + if (code !== 0) { + reject(new Error(stderr.trim() || `mysqldump exited with code ${code}`)); + return; + } + resolve(); + }); + }); +} + +function installedMeshCentralPackageVersion() { + const packageDir = inferredMeshCentralPackageDir(); + const pkg = packageDir ? readJsonFile(path.join(packageDir, 'package.json')) : null; + return pkg && pkg.version ? String(pkg.version) : ''; +} + +function semverParts(version) { + return String(version || '').split('.').map(part => parseInt(part.replace(/[^0-9].*$/, ''), 10) || 0); +} + +function compareSemver(left, right) { + const a = semverParts(left); + const b = semverParts(right); + for (let i = 0; i < Math.max(a.length, b.length); i += 1) { + const diff = (a[i] || 0) - (b[i] || 0); + if (diff) return diff; + } + return 0; +} + +function readInstalledBridgeVersion() { + try { + return fs.readFileSync(path.join(__dirname, 'VERSION'), 'utf8').trim(); + } catch (_) { + return VERSION; + } +} + +async function bridgeUpdateStatus() { + const url = `${config.catalogUrl}${config.catalogUrl.includes('?') ? '&' : '?'}nocache=${Date.now()}`; + const response = await fetch(url, { headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error(`catalog returned HTTP ${response.status}`); + const manifest = await response.json(); + const latest = String(manifest.version || '0.0.0'); + const installed = readInstalledBridgeVersion(); + return { + success: true, + service: 'meshpress-bridge', + runtime_version: VERSION, + installed_version: installed, + latest_version: latest, + update_available: Boolean(latest && compareSemver(installed, latest) < 0), + download_url: manifest.download_url || '', + catalog_url: config.catalogUrl, + manifest_name: manifest.name || manifest.slug || '' + }; +} + +function setEnvValue(key, value) { + const line = `${key}="${String(value).replace(/"/g, '\\"')}"`; + let body = ''; + if (fs.existsSync(ENV_FILE)) body = fs.readFileSync(ENV_FILE, 'utf8'); + const backup = `${ENV_FILE}.old.${Date.now()}`; + fs.mkdirSync(path.dirname(ENV_FILE), { recursive: true }); + if (body) fs.writeFileSync(backup, body, { mode: 0o600 }); + const pattern = new RegExp(`^${key}=.*$`, 'm'); + body = pattern.test(body) ? body.replace(pattern, line) : `${body.replace(/\s*$/, '')}\n${line}\n`; + fs.writeFileSync(ENV_FILE, body, { mode: 0o600 }); +} + +async function latestMeshCentralNpmVersion() { + const response = await fetch('https://registry.npmjs.org/meshcentral/latest', { headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}`); + const data = await response.json(); + return data && data.version ? String(data.version) : ''; +} + +async function meshCentralMaintenanceStatus() { + let serverInfo = null; + let serverInfoError = ''; + try { + serverInfo = await runMeshctrl('serverinfo'); + } catch (error) { + serverInfoError = error.message; + } + let latestVersion = ''; + let updateCheckError = ''; + try { + latestVersion = await latestMeshCentralNpmVersion(); + } catch (error) { + updateCheckError = error.message; + } + const installedVersion = installedMeshCentralPackageVersion(); + const projectDir = inferredMeshCentralProjectDir(); + const packageDir = inferredMeshCentralPackageDir(); + const backups = listMeshCentralBackups(); + const configState = loadMeshCentralConfig(); + const audit = configState.config ? meshCentralConfigAudit(configState.config) : null; + return { + success: true, + bridge_version: VERSION, + bridge_maintenance_enabled: config.allowMaintenance, + maintenance_enabled: config.allowMaintenance, + commands_enabled: config.allowCommands, + meshcentral_project_dir: projectDir, + meshcentral_package_dir: packageDir, + meshcentral_data_dir: config.meshCentralDataDir, + meshcentral_config_path: configState.path, + meshcentral_config_exists: configState.exists, + meshcentral_config_error: configState.error, + meshcentral_maintenance_mode: audit ? audit.maintenance_mode : false, + database: audit ? Object.assign({}, audit.mysql, { password: audit.mysql && audit.mysql.password ? '[redacted]' : '' }) : { type: 'unknown', configured: false }, + meshcentral_service_name: config.meshCentralServiceName, + backup_dir: config.meshCentralBackupDir, + db_dump_timeout_seconds: Math.round(config.dbDumpTimeoutMs / 1000), + backup_stop_service_default: config.backupStopService, + installed_package_version: installedVersion, + latest_npm_version: latestVersion, + update_available: Boolean(installedVersion && latestVersion && compareSemver(installedVersion, latestVersion) < 0), + update_check_error: updateCheckError, + serverinfo: serverInfo, + serverinfo_error: serverInfoError, + backups, + config_audit: audit ? Object.assign({}, audit, { mysql: Object.assign({}, audit.mysql, { password: audit.mysql && audit.mysql.password ? '[redacted]' : '' }) }) : null, + notes: [ + 'MeshCentral browser session links still depend on the browser being logged in to MeshCentral.', + 'Bridge maintenance mode unlocks guarded bridge operations; MeshCentral maintenance mode is a separate setting in MeshCentral config.json.', + 'Backups include MeshCentral config/data plus a MySQL/MariaDB dump when the config uses MySQL/MariaDB.', + 'Set MESHCENTRAL_DATA_DIR or MESHCENTRAL_CONFIG_PATH before using bridge-managed full backups.' + ] + }; +} + +function listMeshCentralBackups() { + try { + if (!fs.existsSync(config.meshCentralBackupDir)) return []; + return fs.readdirSync(config.meshCentralBackupDir) + .filter(name => /^meshcentral-(data|full)-.*\.tar\.gz$/.test(name)) + .map(name => { + const full = path.join(config.meshCentralBackupDir, name); + const stat = fs.statSync(full); + return { name, path: full, size: stat.size, modified: stat.mtime.toISOString() }; + }) + .sort((a, b) => String(b.modified).localeCompare(String(a.modified))); + } catch (error) { + return [{ error: error.message }]; + } +} + +function requireMaintenance(res) { + if (config.allowMaintenance) return true; + res.status(403).json({ + success: false, + error: 'MeshCentral maintenance actions are disabled. Set MESHPRESS_ALLOW_MAINTENANCE=1 on the bridge only after restricting the bridge to a private network or strict IP allowlist.' + }); + return false; +} + +function normalizedSessionMode(mode) { + const normalized = String(mode || '').toLowerCase(); + if (normalized === 'console' || normalized === 'terminal' || normalized === 'shell') return 'terminal'; + if (normalized === 'file' || normalized === 'files') return 'files'; + return normalized; +} + +function sessionViewMode(mode) { + const viewModes = { + desktop: '11', + terminal: '12', + files: '13' + }; + const normalized = normalizedSessionMode(mode); + const viewmode = viewModes[normalized]; + if (!viewmode) throw new Error(`Unknown link mode: ${mode}`); + return { normalized, viewmode }; +} + +function shortNodeId(nodeId) { + const value = String(nodeId || ''); + const parts = value.split('/'); + return parts.length ? (parts[parts.length - 1] || value) : value; +} + +function shellQuote(value) { + return `'${String(value || '').replace(/'/g, `'\\''`)}'`; +} + +function encodeMeshQueryValue(value, rawDollar = false) { + let encoded = encodeURIComponent(String(value || '')); + if (rawDollar) encoded = encoded.replace(/%24/g, '$'); + return encoded; +} + +function meshQuery(pairs, rawDollar = false) { + return pairs.map(([key, value]) => `${encodeURIComponent(key)}=${encodeMeshQueryValue(value, rawDollar)}`).join('&'); +} + +function linkFor(mode, nodeId, linkMode = config.linkMode, order = 'viewmode-first', encoding = 'raw') { + if (!config.publicMeshUrl) throw new Error('PUBLIC_MESH_URL is not configured.'); + const { viewmode } = sessionViewMode(mode); + const nodeParam = linkMode === 'node' ? 'node' : 'gotonode'; + const linkNodeId = linkMode === 'gotonode' ? shortNodeId(nodeId) : nodeId; + const pairs = []; + if (order === 'viewmode-first') { + pairs.push(['viewmode', viewmode], [nodeParam, linkNodeId]); + } else { + pairs.push([nodeParam, linkNodeId], ['viewmode', viewmode]); + } + if (config.linkHide !== '') pairs.push(['hide', config.linkHide]); + return `${config.publicMeshUrl}/?${meshQuery(pairs, encoding === 'raw')}`; +} + +function sessionLinksFor(nodeId, mode) { + const { normalized, viewmode } = sessionViewMode(mode); + const shortId = shortNodeId(nodeId); + return { + mode: normalized, + viewmode, + node_id: nodeId, + short_node_id: shortId, + primary: linkFor(normalized, nodeId, 'gotonode', 'viewmode-first', 'raw'), + gotonode_raw_viewmode_first: linkFor(normalized, nodeId, 'gotonode', 'viewmode-first', 'raw'), + gotonode_encoded_viewmode_first: linkFor(normalized, nodeId, 'gotonode', 'viewmode-first', 'encoded'), + gotonode_raw_node_first: linkFor(normalized, nodeId, 'gotonode', 'node-first', 'raw'), + gotonode_encoded_node_first: linkFor(normalized, nodeId, 'gotonode', 'node-first', 'encoded'), + node_raw_viewmode_first: linkFor(normalized, nodeId, 'node', 'viewmode-first', 'raw'), + node_encoded_viewmode_first: linkFor(normalized, nodeId, 'node', 'viewmode-first', 'encoded'), + gotonode: linkFor(normalized, nodeId, 'gotonode', 'viewmode-first', 'raw'), + node: linkFor(normalized, nodeId, 'node', 'viewmode-first', 'raw'), + gotonode_viewmode_first: linkFor(normalized, nodeId, 'gotonode', 'viewmode-first', 'raw'), + node_viewmode_first: linkFor(normalized, nodeId, 'node', 'viewmode-first', 'raw'), + manual_shape: `${config.publicMeshUrl}/?viewmode=${viewmode}&gotonode=${shortId}` + }; +} + +function allSessionLinksFor(nodeId) { + return { + desktop: sessionLinksFor(nodeId, 'desktop'), + console: sessionLinksFor(nodeId, 'terminal'), + files: sessionLinksFor(nodeId, 'files') + }; +} + +async function diagnoseSession(nodeId) { + const devices = await getDevices(true).catch(() => []); + const resolved = await resolveDeviceId(nodeId); + const resolvedId = resolved.resolved_id || nodeId; + const normalized = resolved.device || devices.find(device => device.id === nodeId || shortNodeId(device.id) === shortNodeId(nodeId)) || null; + let deviceInfo = null; + let deviceInfoError = ''; + try { + deviceInfo = await runMeshctrl('deviceinfo', ['--id', resolvedId]); + } catch (error) { + deviceInfoError = error.message; + } + const links = allSessionLinksFor(resolvedId); + bridgeLog('session_diagnostics', { + requested_node_id: nodeId, + resolved_node_id: resolvedId, + resolved_device_name: normalized ? normalized.name : '', + terminal_primary: links.console.primary + }); + return { + node_id: nodeId, + resolved_node_id: resolvedId, + node_resolution: { + requested: resolved.requested, + resolved_id: resolved.resolved_id, + matched: Boolean(resolved.device), + device: resolved.device, + known_device_count: resolved.known_devices.length + }, + normalized_device: normalized, + raw_device_info: deviceInfo, + raw_device_info_error: deviceInfoError, + links, + hints: [ + 'Terminal is MeshCentral viewmode 12.', + 'The primary URL intentionally preserves dollar signs in gotonode because MeshCentral direct links can fail when those are encoded as %24.', + 'If MeshCentral opens Terminal with a blank title, the node id was not resolved by MeshCentral; compare primary with manual_shape and resolved_node_id.', + 'If the tab opens but says disconnected, compare the node_id here with the raw MeshCentral device _id and confirm the agent reports conn > 0.' + ] + }; +} + +function agentLinksFor(meshId, groupRecord = null) { + if (!config.publicMeshUrl) throw new Error('PUBLIC_MESH_URL is not configured.'); + const canonicalMeshId = groupRecord?.id || groupRecord?._id || meshId; + const encodedMesh = encodeURIComponent(canonicalMeshId); + const shortMeshId = groupRecord?.short_id || shortNodeId(canonicalMeshId); + const linuxInstallerScript = `${config.publicMeshUrl}/meshagents?script=1`; + const linuxInstallCommand = `(wget ${shellQuote(linuxInstallerScript)} --no-check-certificate -O ./meshinstall.sh || wget ${shellQuote(linuxInstallerScript)} --no-proxy --no-check-certificate -O ./meshinstall.sh) && chmod 755 ./meshinstall.sh && sudo -E ./meshinstall.sh ${shellQuote(config.publicMeshUrl)} ${shellQuote(shortMeshId)} || ./meshinstall.sh ${shellQuote(config.publicMeshUrl)} ${shellQuote(shortMeshId)}`; + const linuxCurlInstallCommand = `curl -fsSL ${shellQuote(linuxInstallerScript)} -o ./meshinstall.sh && chmod 755 ./meshinstall.sh && sudo -E ./meshinstall.sh ${shellQuote(config.publicMeshUrl)} ${shellQuote(shortMeshId)}`; + const installerShape = { + short_mesh_id_length: shortMeshId.length, + has_at: shortMeshId.includes('@'), + has_dollar: shortMeshId.includes('$'), + canonical_has_prefix: String(canonicalMeshId).startsWith('mesh//') + }; + return { + mesh_id: canonicalMeshId, + short_mesh_id: shortMeshId, + group_name: groupRecord?.name || '', + linux_install_script_url: linuxInstallerScript, + installer_verified_shape: installerShape, + agent_link_warning: shortMeshId.length < 64 ? 'Resolved MeshCentral group id looks too short. Re-sync the fleet and try again before installing an agent.' : '', + meshcentral_group: `${config.publicMeshUrl}/?gotomesh=${encodedMesh}`, + all_agent_downloads: `${config.publicMeshUrl}/meshagents?meshid=${encodedMesh}`, + mesh_assistant_downloads: `${config.publicMeshUrl}/meshagents?meshid=${encodedMesh}&assistant=1`, + windows_x64_agent: `${config.publicMeshUrl}/meshagents?id=4&meshid=${encodedMesh}&installflags=0`, + windows_x86_agent: `${config.publicMeshUrl}/meshagents?id=3&meshid=${encodedMesh}&installflags=0`, + windows_arm64_agent: `${config.publicMeshUrl}/meshagents?id=43&meshid=${encodedMesh}&installflags=0`, + windows_x64_interactive: `${config.publicMeshUrl}/meshagents?id=4&meshid=${encodedMesh}&installflags=1`, + linux_x64_agent: `${config.publicMeshUrl}/meshagents?id=6&meshid=${encodedMesh}&installflags=0`, + linux_x64_install_script: linuxInstallerScript, + linux_x64_install_command: linuxInstallCommand, + linux_x64_install_command_curl: linuxCurlInstallCommand, + linux_arm64_agent_best_effort: `${config.publicMeshUrl}/meshagents?id=26&meshid=${encodedMesh}&installflags=0`, + macos_x64_agent_best_effort: `${config.publicMeshUrl}/meshagents?id=16&meshid=${encodedMesh}&installflags=0` + }; +} + +app.get('/api/health', auth, (req, res) => { + res.json({ success: true, service: 'meshpress-bridge', version: VERSION }); +}); + +app.get('/api/status', auth, async (req, res) => { + res.json(bridgeStatus()); +}); + +app.get('/api/version', auth, async (req, res) => { + res.json({ + success: true, + service: 'meshpress-bridge', + runtime_version: VERSION, + installed_version: readInstalledBridgeVersion(), + directory: __dirname, + update_script_found: fs.existsSync(path.join(__dirname, 'update.sh')), + catalog_url: config.catalogUrl, + auto_update_enabled: String(process.env.AUTO_UPDATE || '1') !== '0', + self_update_schedule: 'cron: minute 17 hourly via /etc/cron.d/gracepress-meshpress-bridge', + uptime_seconds: Math.round(process.uptime()) + }); +}); + +app.get('/api/update-check', auth, async (req, res) => { + try { + const status = await bridgeUpdateStatus(); + bridgeLog('bridge_update_check', status); + res.json(status); + } catch (error) { + bridgeLog('bridge_update_check_failed', { error: error.message, catalog_url: config.catalogUrl }); + res.status(500).json(publicErrorPayload(error, { catalog_url: config.catalogUrl })); + } +}); + +app.get('/api/self-test', auth, async (req, res) => { + const status = bridgeStatus(); + const checks = []; + + checks.push({ name: 'meshctrl_path', ok: status.meshctrl_found, detail: status.meshctrl_path || 'Set MESHCTRL_PATH.' }); + checks.push({ name: 'auth_method', ok: status.auth_method !== 'none', detail: status.auth_method }); + checks.push({ name: 'public_mesh_url', ok: status.public_mesh_url_configured, detail: status.public_mesh_url || 'Set PUBLIC_MESH_URL.' }); + + try { + const groups = await getGroups(true); + checks.push({ name: 'listdevicegroups', ok: true, count: groups.length }); + } catch (error) { + checks.push({ name: 'listdevicegroups', ok: false, error: error.message }); + } + + try { + const devices = await getDevices(true); + checks.push({ name: 'listdevices', ok: true, count: devices.length }); + } catch (error) { + checks.push({ name: 'listdevices', ok: false, error: error.message }); + } + + const ok = checks.every(check => check.ok); + res.status(ok ? 200 : 500).json({ success: ok, status, checks }); +}); + +app.get('/api/groups', auth, async (req, res) => { + try { + res.json({ success: true, groups: await getGroups(req.query.refresh === '1') }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.get('/api/users', auth, async (req, res) => { + try { + res.json({ success: true, users: await getUsers(req.query.refresh === '1') }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.get('/api/events', auth, async (req, res) => { + try { + const events = await getEvents(req.query.refresh === '1'); + const limit = Math.max(1, Math.min(Number(req.query.limit || 100), 500)); + res.json({ success: true, events: events.slice(0, limit) }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.get('/api/serverinfo', auth, async (req, res) => { + try { + res.json({ success: true, server: await runMeshctrl('serverinfo') }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.get('/api/routes', auth, (req, res) => { + res.json({ + success: true, + version: VERSION, + diagnostic_endpoints: [ + 'GET /api/health', + 'GET /api/version', + 'GET /api/update-check', + 'GET /api/status', + 'GET /api/self-test', + 'GET /api/routes', + 'GET /api/groups?refresh=1', + 'GET /api/groups/resolve/:groupId', + 'GET /api/devices?refresh=1', + 'GET /api/device/:nodeId', + 'GET /api/session-links/:nodeId', + 'GET /api/session-diagnostics/:nodeId', + 'GET /api/meshcentral/maintenance', + 'GET /api/meshcentral/config-audit', + 'GET /api/meshcentral/backups', + 'POST /api/maintenance/toggle', + 'POST /api/meshcentral/maintenance/toggle', + 'POST /api/meshcentral/backup', + 'POST /api/meshcentral/update', + 'POST /api/groups/create', + 'POST /api/groups/edit', + 'POST /api/device/rename', + 'POST /api/device/move', + 'POST /api/device/uninstall', + 'GET /api/agent-links/:groupId' + ], + logging: { + format: '[meshpress-bridge] JSON', + useful_events: ['meshcentral_maintenance_status', 'meshcentral_config_audit', 'meshcentral_maintenance_toggle', 'meshcentral_backup_started', 'meshcentral_backup_db_dumped', 'meshcentral_backup_finished', 'meshcentral_update_started', 'device_uninstall_attempt', 'session_link', 'session_diagnostics', 'group_resolve', 'group_edit_attempt', 'group_edit_rejected_output', 'group_edit_success', 'group_edit_failed', 'agent_links'] + } + }); +}); + +app.get('/api/devices', auth, async (req, res) => { + try { + res.json({ success: true, devices: await getDevices(req.query.refresh === '1') }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.get('/api/device/:nodeId', auth, async (req, res) => { + try { + const raw = await runMeshctrl('deviceinfo', ['--id', req.params.nodeId]); + res.json({ success: true, device: raw }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.get('/api/fleet', auth, async (req, res) => { + try { + res.json(await buildFleet(req.query.refresh === '1')); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +async function buildFleet(refresh = false) { + const groups = await getGroups(refresh); + const devices = await getDevices(refresh); + return { success: true, groups, devices, generated_at: new Date().toISOString(), bridge_version: VERSION }; +} + +async function pushFleet(reason = 'scheduled') { + if (!config.wpDomain || !config.wpApiKey) { + return { success: false, error: 'WP_DOMAIN or WP_API_KEY is not configured.' }; + } + const fleet = await buildFleet(true); + fleet.reason = reason; + const url = `${config.wpDomain}/wp-json/gracepress/v1/meshpress/fleet`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.wpApiKey}` }, + body: JSON.stringify(fleet) + }); + const text = await response.text(); + let body = {}; + try { body = JSON.parse(text); } catch (_) { body = { raw: text.slice(0, 300) }; } + if (!response.ok || body.success === false) { + const error = new Error(body.error || body.message || `WordPress returned HTTP ${response.status}`); + bridgeLog('fleet_push_failed', { reason, status: response.status, error: error.message }); + throw error; + } + bridgeLog('fleet_push_success', { reason, status: response.status, devices: fleet.devices.length, groups: fleet.groups.length }); + return { success: true, status: response.status, body }; +} + +app.post('/api/push-now', auth, async (req, res) => { + try { + res.json(await pushFleet('manual')); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/cache/clear', auth, (req, res) => { + cache.clear(); + res.json({ success: true, message: 'Bridge cache cleared.' }); +}); + +app.get('/api/link/:mode/:nodeId', auth, (req, res) => { + try { + const requested = req.query.link_mode || req.query.linkMode || config.linkMode; + if (requested === 'all') { + res.json({ success: true, mode: normalizedSessionMode(req.params.mode), node_id: req.params.nodeId, links: sessionLinksFor(req.params.nodeId, req.params.mode) }); + return; + } + if (requested === 'both') { + res.json({ + success: true, + mode: normalizedSessionMode(req.params.mode), + node_id: req.params.nodeId, + links: { + gotonode: linkFor(req.params.mode, req.params.nodeId, 'gotonode'), + node: linkFor(req.params.mode, req.params.nodeId, 'node') + } + }); + return; + } + const linkMode = requested === 'node' ? 'node' : 'gotonode'; + const url = linkFor(req.params.mode, req.params.nodeId, linkMode); + bridgeLog('session_link', { mode: normalizedSessionMode(req.params.mode), node_id: req.params.nodeId, link_mode: linkMode, url }); + res.json({ success: true, mode: normalizedSessionMode(req.params.mode), node_id: req.params.nodeId, link_mode: linkMode, url }); + } catch (error) { + res.status(400).json({ success: false, error: error.message }); + } +}); + +app.get('/api/session-links/:nodeId', auth, (req, res) => { + try { + const links = allSessionLinksFor(req.params.nodeId); + bridgeLog('session_links', { node_id: req.params.nodeId, terminal_primary: links.console.primary }); + res.json({ success: true, node_id: req.params.nodeId, links }); + } catch (error) { + res.status(400).json({ success: false, error: error.message }); + } +}); + +app.get('/api/session-diagnostics/:nodeId', auth, async (req, res) => { + try { + res.json({ success: true, diagnostics: await diagnoseSession(req.params.nodeId) }); + } catch (error) { + res.status(400).json({ success: false, error: error.message }); + } +}); + +app.get('/api/meshcentral/maintenance', auth, async (req, res) => { + try { + const status = await meshCentralMaintenanceStatus(); + bridgeLog('meshcentral_maintenance_status', { + installed_package_version: status.installed_package_version, + latest_npm_version: status.latest_npm_version, + update_available: status.update_available, + maintenance_enabled: status.maintenance_enabled + }); + res.json(status); + } catch (error) { + res.status(500).json(publicErrorPayload(error)); + } +}); + +app.get('/api/meshcentral/backups', auth, (req, res) => { + res.json({ success: true, backup_dir: config.meshCentralBackupDir, backups: listMeshCentralBackups() }); +}); + +app.get('/api/meshcentral/config-audit', auth, (req, res) => { + const state = loadMeshCentralConfig(); + if (!state.config) { + res.status(state.exists ? 500 : 404).json({ success: false, path: state.path, exists: state.exists, error: state.error }); + return; + } + const audit = meshCentralConfigAudit(state.config); + bridgeLog('meshcentral_config_audit', { + path: state.path, + maintenance_mode: audit.maintenance_mode, + mysql_configured: audit.mysql.configured, + recommendation_count: audit.recommendations.length + }); + res.json({ + success: true, + path: state.path, + redacted_config: redactSecrets(state.config), + audit: Object.assign({}, audit, { mysql: Object.assign({}, audit.mysql, { password: audit.mysql.password ? '[redacted]' : '' }) }) + }); +}); + +app.post('/api/meshcentral/maintenance/toggle', auth, async (req, res) => { + if (!requireMaintenance(res)) return; + const enabled = req.body.enabled === true || req.body.enabled === '1' || req.body.enabled === 1; + const restart = req.body.restart !== false && req.body.restart !== '0'; + const state = loadMeshCentralConfig(); + if (!state.config) { + res.status(state.exists ? 500 : 404).json({ success: false, path: state.path, exists: state.exists, error: state.error }); + return; + } + try { + state.config.settings = state.config.settings || {}; + state.config.settings.maintenanceMode = enabled; + const backup = writeMeshCentralConfig(state.path, state.config); + let restart_output = ''; + if (restart) { + restart_output = await serviceAction('restart'); + } + appendMaintenanceLog('meshcentral_maintenance_toggle', { enabled, restart, config_path: state.path, config_backup: backup }); + res.json({ + success: true, + meshcentral_maintenance_mode: enabled, + config_path: state.path, + config_backup: backup, + restarted: restart, + restart_output + }); + } catch (error) { + appendMaintenanceLog('meshcentral_maintenance_toggle_failed', { enabled, error: error.message }); + res.status(500).json(publicErrorPayload(error)); + } +}); + +app.post('/api/meshcentral/backup', auth, async (req, res) => { + if (!requireMaintenance(res)) return; + const state = loadMeshCentralConfig(); + const dataDir = config.meshCentralDataDir || meshCentralDataDirFromConfigPath(state.path); + if (!dataDir) { + res.status(400).json({ success: false, error: 'MESHCENTRAL_DATA_DIR or MESHCENTRAL_CONFIG_PATH is not configured on the bridge.' }); + return; + } + if (!fs.existsSync(dataDir)) { + res.status(400).json({ success: false, error: `MeshCentral data directory does not exist: ${dataDir}` }); + return; + } + try { + fs.mkdirSync(config.meshCentralBackupDir, { recursive: true, mode: 0o700 }); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const workDir = path.join(config.meshCentralBackupDir, `.meshpress-full-${stamp}`); + const backupFile = path.join(config.meshCentralBackupDir, `meshcentral-full-${stamp}.tar.gz`); + const manifestFile = path.join(workDir, 'manifest.json'); + const db = state.config ? meshCentralDbConfig(state.config) : { type: 'unknown', configured: false }; + const stopService = req.body && ('stop_service' in req.body) ? (req.body.stop_service === true || req.body.stop_service === '1' || req.body.stop_service === 1) : config.backupStopService; + fs.mkdirSync(workDir, { recursive: true, mode: 0o700 }); + appendMaintenanceLog('meshcentral_backup_started', { data_dir: dataDir, config_path: state.path, backup_file: backupFile, database_type: db.type, stop_service: stopService }); + let stopped = false; + let dbDump = null; + try { + if (stopService) { + await serviceAction('stop'); + stopped = true; + appendMaintenanceLog('meshcentral_backup_service_stopped', { service_name: config.meshCentralServiceName }); + } + if (db.configured) { + if (!db.user || !db.database) throw new Error('MeshCentral MySQL/MariaDB config is missing user or database.'); + dbDump = path.join(workDir, `${db.database}-${stamp}.sql`); + await dumpDatabaseToFile(db, dbDump); + appendMaintenanceLog('meshcentral_backup_db_dumped', { database: db.database, dump_file: dbDump, size: fs.statSync(dbDump).size }); + } + const manifest = { + created_at: new Date().toISOString(), + bridge_version: VERSION, + meshcentral_package_version: installedMeshCentralPackageVersion(), + meshcentral_project_dir: inferredMeshCentralProjectDir(), + meshcentral_data_dir: dataDir, + meshcentral_config_path: state.path, + database: Object.assign({}, db, { password: db.password ? '[redacted]' : '' }), + db_dump_file: dbDump ? path.basename(dbDump) : '', + stopped_service_for_backup: stopped, + service_name: config.meshCentralServiceName + }; + fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 }); + const parent = path.dirname(dataDir); + const base = path.basename(dataDir); + const tarArgs = ['-czf', backupFile, '-C', parent, base, '-C', workDir, 'manifest.json']; + if (dbDump) tarArgs.push('-C', workDir, path.basename(dbDump)); + await runCommand('tar', tarArgs, { timeout: 10 * 60 * 1000 }); + } finally { + if (stopped) { + await serviceAction('start').catch(error => appendMaintenanceLog('meshcentral_backup_service_restart_failed', { error: error.message })); + } + fs.rmSync(workDir, { recursive: true, force: true }); + } + fs.chmodSync(backupFile, 0o600); + const size = fs.statSync(backupFile).size; + const sha256 = sha256File(backupFile); + appendMaintenanceLog('meshcentral_backup_finished', { backup_file: backupFile, size, sha256 }); + res.json({ success: true, backup_file: backupFile, size, sha256, database_included: db.configured, stopped_service_for_backup: stopService, backups: listMeshCentralBackups() }); + } catch (error) { + appendMaintenanceLog('meshcentral_backup_failed', { error: error.message }); + res.status(500).json(publicErrorPayload(error)); + } +}); + +app.post('/api/meshcentral/update', auth, async (req, res) => { + if (!requireMaintenance(res)) return; + const projectDir = inferredMeshCentralProjectDir(); + if (!projectDir || !fs.existsSync(projectDir)) { + res.status(400).json({ success: false, error: 'Could not locate MeshCentral project directory. Set MESHCENTRAL_DIR on the bridge.' }); + return; + } + try { + const logFile = config.maintenanceLogFile; + fs.mkdirSync(path.dirname(logFile), { recursive: true }); + const out = fs.openSync(logFile, 'a'); + const script = [ + 'set -euo pipefail', + `cd ${JSON.stringify(projectDir)}`, + 'echo "[meshcentral-update] $(date -Is) starting npm update"', + 'npm install meshcentral@latest', + `if command -v pm2 >/dev/null 2>&1; then pm2 restart ${JSON.stringify(config.meshCentralServiceName)} --update-env || true; fi`, + 'echo "[meshcentral-update] $(date -Is) finished"' + ].join('\n'); + const child = spawn('/bin/bash', ['-lc', script], { + detached: true, + stdio: ['ignore', out, out], + env: process.env + }); + child.unref(); + appendMaintenanceLog('meshcentral_update_started', { project_dir: projectDir, service_name: config.meshCentralServiceName, pid: child.pid, log_file: logFile }); + res.json({ success: true, message: 'MeshCentral update started.', project_dir: projectDir, service_name: config.meshCentralServiceName, log_file: logFile, pid: child.pid }); + } catch (error) { + appendMaintenanceLog('meshcentral_update_failed', { error: error.message }); + res.status(500).json(publicErrorPayload(error)); + } +}); + +app.get('/api/groups/resolve/:groupId', auth, async (req, res) => { + try { + const resolution = await resolveGroupId(req.params.groupId); + bridgeLog('group_resolve', { + requested_group_id: resolution.requested, + resolved_group_id: resolution.resolved_id, + matched_group_name: resolution.group ? resolution.group.name : '' + }); + res.json({ success: true, resolution }); + } catch (error) { + bridgeLog('group_resolve_failed', { requested_group_id: req.params.groupId, error: error.message }); + res.status(500).json(publicErrorPayload(error)); + } +}); + +app.post('/api/update-now', auth, (req, res) => { + const script = path.join(__dirname, 'update.sh'); + if (!fs.existsSync(script)) { + res.status(404).json({ success: false, error: 'update.sh was not found in the bridge install directory.' }); + return; + } + const logFile = '/var/log/gracepress-meshpress-bridge-update.log'; + try { + const out = fs.openSync(logFile, 'a'); + const child = spawn('/bin/bash', [script], { + cwd: '/', + detached: true, + stdio: ['ignore', out, out], + env: process.env + }); + child.unref(); + res.json({ success: true, message: 'Bridge update started. The service may restart if an update is available.', log_file: logFile, pid: child.pid }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/maintenance/toggle', auth, (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'Bridge command actions are disabled. Set MESHPRESS_ALLOW_COMMANDS=1 before toggling maintenance mode.' }); + return; + } + const enabled = req.body.enabled === true || req.body.enabled === '1' || req.body.enabled === 1; + try { + setEnvValue('MESHPRESS_ALLOW_MAINTENANCE', enabled ? '1' : '0'); + config.allowMaintenance = enabled; + bridgeLog('maintenance_toggle', { enabled }); + res.json({ + success: true, + maintenance_enabled: enabled, + message: enabled ? 'Maintenance mode is enabled for this running bridge and persisted in env.' : 'Maintenance mode is disabled for this running bridge and persisted in env.' + }); + } catch (error) { + res.status(500).json(publicErrorPayload(error)); + } +}); + +app.get('/api/agent-links/:groupId', auth, async (req, res) => { + try { + const resolved = await resolveGroupId(req.params.groupId, { refresh: true }); + if (!resolved.matched) { + res.status(404).json({ + success: false, + error: 'Group was not found. Refresh the fleet cache, then choose the group again.', + requested_group_id: req.params.groupId, + candidates: groupIdCandidates(resolved), + known_groups: resolved.known_groups + }); + return; + } + const links = agentLinksFor(resolved.resolved_id, resolved.group); + bridgeLog('agent_links', { group_id: resolved.resolved_id, group_name: resolved.group ? resolved.group.name : '', link_count: Object.keys(links).length }); + res.json({ + success: true, + group: resolved.group, + links, + hints: [ + 'For Linux, copy linux_x64_install_command and run it on the target machine.', + 'If a platform-specific link fails, open all_agent_downloads and choose the exact agent from MeshCentral.', + 'Best-effort macOS/Linux ARM links depend on MeshCentral agent id support in your installed MeshCentral build.' + ] + }); + } catch (error) { + res.status(400).json({ success: false, error: error.message }); + } +}); + +app.post('/api/groups/create', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral group creation is disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); + return; + } + const name = String(req.body.name || '').trim(); + const description = String(req.body.description || '').trim(); + if (!name) { + res.status(400).json({ success: false, error: 'Group name is required.' }); + return; + } + try { + const before = await getGroups(true).catch(() => []); + const beforeIds = new Set(before.map(g => g.id)); + const output = await runMeshctrlText('adddevicegroup', ['--name', name, '--desc', description || `Created by MeshPress Bridge ${new Date().toISOString()}`]); + cache.delete('groups'); + cache.delete('devices'); + const groups = await getGroups(true); + const created = groups.find(g => !beforeIds.has(g.id) && g.name === name) || groups.find(g => g.name === name) || null; + if (!created) { + res.json({ success: true, message: output, group: null, groups }); + return; + } + res.json({ success: true, message: output, group: created, links: agentLinksFor(created.id) }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/groups/remove', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral group removal is disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); + return; + } + const id = String(req.body.id || req.body.group_id || '').trim(); + if (!id) { + res.status(400).json({ success: false, error: 'Group id is required.' }); + return; + } + try { + const output = await runMeshctrlText('removedevicegroup', ['--id', id]); + cache.delete('groups'); + cache.delete('devices'); + res.json({ success: true, message: output }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/groups/edit', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral group editing is disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); + return; + } + const id = String(req.body.id || req.body.group_id || '').trim(); + const name = String(req.body.name || '').trim(); + const description = String(req.body.description || '').trim(); + if (!id) { + res.status(400).json({ success: false, error: 'Group id is required.' }); + return; + } + try { + const resolution = await resolveGroupId(id); + const candidates = groupIdCandidates(resolution); + const groupArgs = (flag, groupId, descFlag = '--desc') => { + const args = [flag, groupId]; + if (name) args.push('--name', name); + if (description) args.push(descFlag, description); + return args; + }; + const attempts = []; + const seenAttempts = new Set(); + const addAttempt = (command, args, candidate, flag, descFlag) => { + const key = [command].concat(args).join('\u0000'); + if (seenAttempts.has(key)) return; + seenAttempts.add(key); + attempts.push({ command, args, candidate, flag, descFlag }); + }; + for (const candidate of candidates) { + addAttempt('editdevicegroup', groupArgs('--id', candidate, '--desc'), candidate, '--id', '--desc'); + addAttempt('editdevicegroup', groupArgs('--groupid', candidate, '--desc'), candidate, '--groupid', '--desc'); + addAttempt('editdevicegroup', groupArgs('--id', candidate, '--description'), candidate, '--id', '--description'); + addAttempt('editdevicegroup', groupArgs('--groupid', candidate, '--description'), candidate, '--groupid', '--description'); + } + const errors = []; + const rejectedOutputs = []; + let output = ''; + let successfulAttempt = null; + let updatedGroup = null; + let refreshedGroups = []; + for (const attempt of attempts) { + bridgeLog('group_edit_attempt', { + requested_group_id: id, + resolved_group_id: resolution.resolved_id, + candidate_group_id: attempt.candidate, + matched_group_name: resolution.group ? resolution.group.name : '', + command: attempt.command, + args: attempt.args + }); + try { + const attemptOutput = await runMeshctrlText(attempt.command, attempt.args); + if (isMeshctrlFailureText(attemptOutput)) { + const rejected = { command: attempt.command, args: attempt.args, output: attemptOutput }; + rejectedOutputs.push(rejected); + bridgeLog('group_edit_rejected_output', { + requested_group_id: id, + resolved_group_id: resolution.resolved_id, + candidate_group_id: attempt.candidate, + command: attempt.command, + args: attempt.args, + output: attemptOutput + }); + continue; + } + cache.delete('groups'); + cache.delete('devices'); + refreshedGroups = await getGroups(true); + updatedGroup = refreshedGroups.find(group => groupMatchesCandidate(group, [attempt.candidate, resolution.resolved_id, resolution.resolved_short_id, id])) || null; + const verification = verifyGroupEdit(updatedGroup, name, description); + if (!verification.ok) { + rejectedOutputs.push({ + command: attempt.command, + args: attempt.args, + output: attemptOutput, + verification_failed: true, + verification, + matched_group_after_attempt: updatedGroup + }); + bridgeLog('group_edit_unverified', { + requested_group_id: id, + resolved_group_id: resolution.resolved_id, + candidate_group_id: attempt.candidate, + command: attempt.command, + args: attempt.args, + output: attemptOutput, + verification, + matched_group_after_attempt: updatedGroup + }); + continue; + } + output = attemptOutput || 'ok'; + successfulAttempt = attempt; + bridgeLog('group_edit_verified_success', { + requested_group_id: id, + resolved_group_id: resolution.resolved_id, + candidate_group_id: attempt.candidate, + command: attempt.command, + args: attempt.args, + output, + updated_group: updatedGroup + }); + break; + } catch (error) { + errors.push({ command: attempt.command, args: attempt.args, error: error.message }); + output = ''; + } + } + if (!output) { + const rejectedSummary = rejectedOutputs.map(item => item.output).filter(Boolean).join(' | '); + const verificationSummary = rejectedOutputs + .filter(item => item.verification_failed && item.verification && item.verification.mismatches) + .flatMap(item => item.verification.mismatches) + .join(' | '); + const error = new Error(errors.map(item => item.error).join(' | ') || verificationSummary || rejectedSummary || 'Group edit failed.'); + bridgeLog('group_edit_failed', { + requested_group_id: id, + resolved_group_id: resolution.resolved_id, + matched_group_name: resolution.group ? resolution.group.name : '', + errors, + rejected_outputs: rejectedOutputs, + known_groups: resolution.known_groups + }); + res.status(500).json(publicErrorPayload(error, { + requested_group_id: id, + resolved_group_id: resolution.resolved_id, + matched_group: resolution.group, + candidates, + attempts, + errors, + rejected_outputs: rejectedOutputs, + known_groups: resolution.known_groups + })); + return; + } + res.json({ + success: true, + verified: true, + message: output, + requested_group_id: id, + resolved_group_id: resolution.resolved_id, + candidate_group_id: successfulAttempt ? successfulAttempt.candidate : '', + matched_group: resolution.group, + updated_group: updatedGroup, + groups: refreshedGroups + }); + } catch (error) { + bridgeLog('group_edit_failed', { requested_group_id: id, error: error.message }); + res.status(500).json(publicErrorPayload(error)); + } +}); + +app.post('/api/groups/add-user', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral group membership changes are disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); + return; + } + const id = String(req.body.id || req.body.group_id || '').trim(); + const userId = String(req.body.userid || req.body.user_id || '').trim(); + const rights = Array.isArray(req.body.rights) ? req.body.rights : ['fullrights']; + if (!id || !userId) { + res.status(400).json({ success: false, error: 'Group id and user id are required.' }); + return; + } + const allowedRights = new Set(['fullrights', 'editgroup', 'manageusers', 'managedevices', 'remotecontrol', 'agentconsole', 'serverfiles', 'wakedevices', 'notes', 'desktopviewonly', 'limiteddesktop', 'noterminal', 'nofiles', 'noamt']); + const args = ['--id', id, '--userid', userId]; + for (const right of rights) { + if (allowedRights.has(String(right))) args.push(`--${right}`); + } + try { + const output = await runMeshctrlText('addusertodevicegroup', args); + cache.delete('groups'); + res.json({ success: true, message: output }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/groups/remove-user', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral group membership changes are disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); + return; + } + const id = String(req.body.id || req.body.group_id || '').trim(); + const userId = String(req.body.userid || req.body.user_id || '').trim(); + if (!id || !userId) { + res.status(400).json({ success: false, error: 'Group id and user id are required.' }); + return; + } + try { + const output = await runMeshctrlText('removeuserfromdevicegroup', ['--id', id, '--userid', userId]); + cache.delete('groups'); + res.json({ success: true, message: output }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/invite-link', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral invite link generation is disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); + return; + } + const groupId = String(req.body.group_id || req.body.id || '').trim(); + if (!groupId) { + res.status(400).json({ success: false, error: 'Group id is required.' }); + return; + } + const args = ['--id', groupId]; + if (req.body.hours) args.push('--hours', String(req.body.hours)); + if (req.body.flags) args.push('--flags', String(req.body.flags)); + try { + const output = await runMeshctrlText('generateinvitelink', args); + res.json({ success: true, message: output, raw: output }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/device/message', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral device messaging is disabled on this bridge.' }); + return; + } + const nodeId = String(req.body.node_id || req.body.nodeId || '').trim(); + const message = String(req.body.message || '').trim(); + if (!nodeId || !message) { + res.status(400).json({ success: false, error: 'Node id and message are required.' }); + return; + } + try { + const output = await runMeshctrlText('devicemessage', ['--id', nodeId, '--msg', message]); + res.json({ success: true, message: output }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/device/toast', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral device toast is disabled on this bridge.' }); + return; + } + const nodeId = String(req.body.node_id || req.body.nodeId || '').trim(); + const message = String(req.body.message || '').trim(); + if (!nodeId || !message) { + res.status(400).json({ success: false, error: 'Node id and message are required.' }); + return; + } + try { + const output = await runMeshctrlText('devicetoast', ['--id', nodeId, '--msg', message]); + res.json({ success: true, message: output }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/device/rename', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral device editing is disabled on this bridge.' }); + return; + } + const nodeId = String(req.body.node_id || req.body.nodeId || req.body.id || '').trim(); + const name = String(req.body.name || '').trim(); + if (!nodeId || !name) { + res.status(400).json({ success: false, error: 'Node id and new name are required.' }); + return; + } + try { + const output = await runMeshctrlText('editdevice', ['--id', nodeId, '--name', name]); + cache.delete('devices'); + res.json({ success: true, message: output }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/device/move', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral device moving is disabled on this bridge.' }); + return; + } + const nodeId = String(req.body.node_id || req.body.nodeId || req.body.id || '').trim(); + const groupId = String(req.body.group_id || req.body.groupId || req.body.meshid || '').trim(); + if (!nodeId || !groupId) { + res.status(400).json({ success: false, error: 'Node id and destination group id are required.' }); + return; + } + try { + const output = await runMeshctrlText('movedevice', ['--id', nodeId, '--groupid', groupId]); + cache.delete('devices'); + res.json({ success: true, message: output }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/device/uninstall', auth, async (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral agent uninstall is disabled on this bridge.' }); + return; + } + const nodeId = String(req.body.node_id || req.body.nodeId || req.body.id || '').trim(); + if (!nodeId) { + res.status(400).json({ success: false, error: 'Node id is required.' }); + return; + } + try { + const resolved = await resolveDeviceId(nodeId); + const id = resolved.resolved_id || nodeId; + const attempts = [ + { command: 'deviceuninstall', args: ['--id', id] }, + { command: 'uninstallagent', args: ['--id', id] }, + { command: 'deviceaction', args: ['--id', id, '--action', 'uninstallagent'] } + ]; + const errors = []; + for (const attempt of attempts) { + bridgeLog('device_uninstall_attempt', { requested_node_id: nodeId, resolved_node_id: id, command: attempt.command, args: attempt.args }); + try { + const output = await runMeshctrlText(attempt.command, attempt.args); + if (isMeshctrlFailureText(output)) { + errors.push({ command: attempt.command, args: attempt.args, output }); + continue; + } + cache.delete('devices'); + bridgeLog('device_uninstall_success', { requested_node_id: nodeId, resolved_node_id: id, command: attempt.command, output }); + res.json({ success: true, message: output, command: attempt.command, resolved_node_id: id, matched_device: resolved.device }); + return; + } catch (error) { + errors.push({ command: attempt.command, args: attempt.args, error: error.message }); + } + } + const error = new Error('No supported MeshCtrl uninstall command succeeded.'); + bridgeLog('device_uninstall_failed', { requested_node_id: nodeId, resolved_node_id: id, errors }); + res.status(500).json(publicErrorPayload(error, { resolved_node_id: id, matched_device: resolved.device, attempts, errors })); + } catch (error) { + bridgeLog('device_uninstall_failed', { requested_node_id: nodeId, error: error.message }); + res.status(500).json(publicErrorPayload(error)); + } +}); + +app.post('/api/action', auth, (req, res) => { + if (!config.allowCommands) { + res.status(403).json({ success: false, error: 'MeshCentral command dispatch is disabled on this bridge.' }); + return; + } + res.status(501).json({ success: false, error: 'Command dispatch is reserved for a later hardening pass.' }); +}); + +app.use((req, res) => { + res.status(404).json({ success: false, error: 'Not found' }); +}); + +app.listen(config.port, config.bind, () => { + console.log(`[meshpress-bridge] v${VERSION} listening on ${config.bind}:${config.port}`); + if (!config.token) console.error('[meshpress-bridge] SECURITY: MESHPRESS_BRIDGE_TOKEN is empty; all protected endpoints will reject requests.'); + if (config.bind === '0.0.0.0' && !config.allowedIps.length) console.error('[meshpress-bridge] SECURITY: listening on 0.0.0.0 without MESHPRESS_ALLOWED_IPS. Use firewall/VPN controls before exposing this port.'); + if (config.wpDomain && config.wpApiKey && config.pushIntervalSeconds > 0) { + console.log(`[meshpress-bridge] fleet push enabled every ${config.pushIntervalSeconds}s to ${config.wpDomain}`); + setTimeout(() => pushFleet('startup').catch(error => console.error(`[meshpress-bridge] startup push failed: ${error.message}`)), 5000); + setInterval(() => pushFleet('scheduled').catch(error => console.error(`[meshpress-bridge] scheduled push failed: ${error.message}`)), config.pushIntervalSeconds * 1000); + } +}); diff --git a/bridge/meshpress-bridge/update.sh b/bridge/meshpress-bridge/update.sh new file mode 100644 index 0000000..eb89556 --- /dev/null +++ b/bridge/meshpress-bridge/update.sh @@ -0,0 +1,109 @@ +#!/bin/bash +set -euo pipefail + +COMPONENT="meshpress-bridge" +TARGET_DIR="/opt/gracepress-meshpress-bridge" +ENV_FILE="/etc/gracepress/meshpress-bridge.env" +SERVICE_NAME="gp-meshpress-bridge" +LOG_PREFIX="[meshpress-bridge-update]" +LOCK_FILE="/tmp/gracepress-meshpress-bridge-update.lock" +TMP_DIR="$(mktemp -d)" +STAGE_DIR="" +BACKUP_DIR="" + +cleanup() { + rm -rf "$TMP_DIR" + if [ -n "$STAGE_DIR" ] && [ -d "$STAGE_DIR" ]; then rm -rf "$STAGE_DIR"; fi +} +trap cleanup EXIT + +log() { echo "$LOG_PREFIX $(date -Is) $*"; } + +if [ "$(id -u)" -ne 0 ]; then + log "Please run as root." + exit 1 +fi + +exec 9>"$LOCK_FILE" +if command -v flock >/dev/null 2>&1; then + flock -n 9 || { log "Another bridge update is already running."; exit 1; } +fi + +[ -f "$ENV_FILE" ] && . "$ENV_FILE" +CATALOG_URL="${GRACEPRESS_CATALOG_URL:-https://christit.com/gracepress/api.php?endpoint=manifest&slug=meshpress-bridge}" + +apt-get update -y >/dev/null +apt-get install -y curl unzip python3 ca-certificates nodejs npm >/dev/null +if ! command -v pm2 >/dev/null 2>&1; then + npm install -g pm2 >/dev/null +fi + +log "Checking catalog: $CATALOG_URL" +curl -fsSL "$CATALOG_URL&nocache=$(date +%s)" -o "$TMP_DIR/manifest.json" +DOWNLOAD_URL="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("download_url",""))' "$TMP_DIR/manifest.json")" +VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version","0.0.0"))' "$TMP_DIR/manifest.json")" + +if [ -z "$DOWNLOAD_URL" ]; then + log "No download_url found in catalog manifest." + exit 1 +fi + +CURRENT="$(cat "$TARGET_DIR/VERSION" 2>/dev/null || echo 0.0.0)" +if [ "$CURRENT" = "$VERSION" ]; then + log "$COMPONENT is already current: $CURRENT" + exit 0 +fi + +log "Updating $COMPONENT from $CURRENT to $VERSION" +curl -fL "$DOWNLOAD_URL" -o "$TMP_DIR/package.zip" +unzip -q "$TMP_DIR/package.zip" -d "$TMP_DIR/pkg" +PKG_DIR="$(find "$TMP_DIR/pkg" -maxdepth 3 -type f -name server.js -printf '%h\n' | head -n 1)" +if [ -z "$PKG_DIR" ] || [ ! -f "$PKG_DIR/package.json" ]; then + log "Package does not contain a valid bridge payload." + exit 1 +fi + +STAGE_DIR="${TARGET_DIR}.next.${VERSION}.$$" +rm -rf "$STAGE_DIR" +mkdir -p "$STAGE_DIR" +cp -a "$PKG_DIR"/. "$STAGE_DIR"/ +echo "$VERSION" > "$STAGE_DIR/VERSION" +chmod +x "$STAGE_DIR/update.sh" "$STAGE_DIR/meshpress-bridge" 2>/dev/null || true + +log "Installing node dependencies in staging directory." +(cd "$STAGE_DIR" && npm install --omit=dev >/dev/null && node --check server.js >/dev/null) + +BACKUP_DIR="${TARGET_DIR}.old.$(date +%Y%m%d%H%M%S)" +if [ -d "$TARGET_DIR" ]; then + mv "$TARGET_DIR" "$BACKUP_DIR" + log "Previous install moved to $BACKUP_DIR" +fi + +mv "$STAGE_DIR" "$TARGET_DIR" +STAGE_DIR="" + +rollback() { + log "Update failed after swap; attempting rollback." + local failed="${TARGET_DIR}.failed.$(date +%Y%m%d%H%M%S)" + if [ -d "$TARGET_DIR" ]; then mv "$TARGET_DIR" "$failed"; fi + if [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ]; then mv "$BACKUP_DIR" "$TARGET_DIR"; fi + if [ -f "$TARGET_DIR/server.js" ]; then + pm2 start "$TARGET_DIR/server.js" --name "$SERVICE_NAME" --time --max-memory-restart 512M >/dev/null 2>&1 || pm2 restart "$SERVICE_NAME" --update-env >/dev/null 2>&1 || true + pm2 save >/dev/null 2>&1 || true + fi +} + +if pm2 describe "$SERVICE_NAME" >/dev/null 2>&1; then + if ! (cd "$TARGET_DIR" && pm2 restart "$SERVICE_NAME" --update-env >/dev/null); then + rollback + exit 1 + fi +else + if ! (cd "$TARGET_DIR" && pm2 start server.js --name "$SERVICE_NAME" --time --max-memory-restart 512M >/dev/null); then + rollback + exit 1 + fi +fi + +pm2 save >/dev/null +log "$COMPONENT updated to $VERSION" diff --git a/dist/current/gracepress-theme-studio.changelog.md b/dist/current/gracepress-theme-studio.changelog.md new file mode 100644 index 0000000..bafa0a9 --- /dev/null +++ b/dist/current/gracepress-theme-studio.changelog.md @@ -0,0 +1,19 @@ +# Changelog + +## 0.0.3 - Profile editor foundation +- Added Theme Studio profile storage with a locked MeshCentral Default profile and editable cloned theme profiles. +- Added profile dropdown selection, clone current profile, save profile, and export JSON actions. +- Replaced the raw inventory-first screen with grouped Brand, Colors, Typography, Layout, Icons, and Advanced controls. +- Added dropdown/select controls for fonts, density, icon set, icon size, spacing-style values, and standard color pickers for color fields. +- Kept Apply and startup CSS hook controls disabled until generated CSS, rollback, and service-load behavior are implemented. + +## 0.0.2 - MeshCentral-safe runtime identity +- Changed the MeshCentral runtime `shortName` to `gpthemestudio` so MeshCentral's alphanumeric admin route guard can open the backend panel. +- Renamed the runtime entrypoint to `gpthemestudio.js` and aligned the JavaScript export with the MeshCentral loader. +- Preserved the GracePress catalog slug `gracepress-theme-studio` and the existing catalog facade URLs. + +## 0.0.1 - Default theme inventory +- Created the initial GracePress Theme Studio MeshCentral plugin. +- Captured the current classic MeshCentral UI as a locked `MeshCentral Default` theme profile. +- Added a backend admin panel for reviewing theme variables and future theming capabilities. +- Kept this first release read-only; it does not inject CSS, alter templates, or modify MeshCentral core files. diff --git a/dist/current/gracepress-theme-studio.download.php b/dist/current/gracepress-theme-studio.download.php new file mode 100644 index 0000000..9361557 --- /dev/null +++ b/dist/current/gracepress-theme-studio.download.php @@ -0,0 +1,57 @@ + 0) { + ob_end_clean(); +} + +header('Access-Control-Allow-Origin: *'); +header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); +header('Pragma: no-cache'); +header('Content-Type: application/zip'); +header('Content-Disposition: attachment; filename="' . $zip . '"'); +header('Content-Length: ' . filesize($file)); +header('X-GracePress-Download-Url: ' . $download); + +if ($_SERVER['REQUEST_METHOD'] !== 'HEAD') { + readfile($file); +} \ No newline at end of file diff --git a/dist/current/gracepress-theme-studio.json b/dist/current/gracepress-theme-studio.json new file mode 100644 index 0000000..be689fc --- /dev/null +++ b/dist/current/gracepress-theme-studio.json @@ -0,0 +1,29 @@ +{ + "name": "GracePress Theme Studio", + "slug": "gracepress-theme-studio", + "type": "mcplugin", + "category": "MeshCentral", + "version": "0.0.3", + "stage": "Alpha", + "release_channel": "Internal", + "public_release": "No", + "public_candidate": "No", + "wp_org_slug": "", + "description": "MeshCentral UI theme inventory and profile editor for GracePress-managed MeshCentral servers.", + "icon": "🧩", + "requires_php": "8.0", + "changelog_url": "https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress-theme-studio", + "changelog_updated": "2026-06-27 18:51:46 UTC", + "download_url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.3.zip", + "file": "gracepress-theme-studio/gpthemestudio.js", + "settings_slug": "gracepress-theme-studio", + "last_updated": "2026-06-27 18:53:57 UTC", + "meshcentral": { + "short_name": "gpthemestudio", + "has_admin_panel": true, + "main": "gpthemestudio.js", + "config_url": "https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json" + }, + "meshcentral_config_url": "https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json", + "install_type": "meshcentral-plugin" +} diff --git a/dist/current/gracepress-theme-studio.mcplugin.json b/dist/current/gracepress-theme-studio.mcplugin.json new file mode 100644 index 0000000..7d54f91 --- /dev/null +++ b/dist/current/gracepress-theme-studio.mcplugin.json @@ -0,0 +1,26 @@ +{ + "name": "GracePress Theme Studio", + "shortName": "gpthemestudio", + "version": "0.0.3", + "description": "MeshCentral UI theme profile editor for GracePress-managed MeshCentral servers.", + "author": "Jason Eugene Garrison", + "homepage": "https://christit.com", + "repository": { + "type": "git", + "url": "https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress-theme-studio" + }, + "hasAdminPanel": true, + "main": "gpthemestudio.js", + "configUrl": "https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json", + "downloadUrl": "https://christit.com/gracepress/gracepress-theme-studio.download.php", + "changelogUrl": "https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress-theme-studio", + "versionHistoryUrl": "https://christit.com/gracepress/gracepress-theme-studio.tags.json", + "meshCentralCompat": ">=1.1.0", + "download_url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.3.zip", + "gracepress": { + "slug": "gracepress-theme-studio", + "type": "mcplugin", + "built_at": "2026-06-27 18:53:57 UTC", + "manifest_url": "https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress-theme-studio" + } +} diff --git a/dist/current/gracepress-theme-studio.tags.json b/dist/current/gracepress-theme-studio.tags.json new file mode 100644 index 0000000..cc355c3 --- /dev/null +++ b/dist/current/gracepress-theme-studio.tags.json @@ -0,0 +1,20 @@ +[ + { + "name": "0.0.3", + "zipball_url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.3.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.3.zip", + "url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.3.zip" + }, + { + "name": "0.0.2", + "zipball_url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.2.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.2.zip", + "url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.2.zip" + }, + { + "name": "0.0.1", + "zipball_url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.1.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.1.zip", + "url": "https://christit.com/gracepress/gracepress-theme-studio-v0.0.1.zip" + } +] diff --git a/dist/current/gracepress.changelog.md b/dist/current/gracepress.changelog.md new file mode 100644 index 0000000..26aa12f --- /dev/null +++ b/dist/current/gracepress.changelog.md @@ -0,0 +1,91 @@ +# Changelog + +## 0.0.18 - Theme Studio 0.0.3 catalog seed +- Updated the built-in GracePress Theme Studio catalog seed to version `0.0.3`. +- Changed the Theme Studio description to match the new profile editor role. +- Keeps the MeshCentral package manager authoritative over GracePress-managed plugin rows while preserving unrelated third-party plugin rows. + +## 0.0.17 - GracePress row ownership cleanup +- Resolves MeshCentral plugin registry row IDs from `_id`, `id`, or `pluginId` so cleanup works across MeshCentral database adapters. +- Archives and removes GracePress-owned legacy rows when their short name no longer matches the GracePress catalog entry. +- Archives and removes duplicate GracePress-owned rows for the same managed MeshCentral short name while preserving the active row when one exists. +- Reports missing row IDs as explicit sync errors instead of silently leaving obsolete GracePress rows in the MeshCentral plugin catalog. +- Keeps cleanup scoped to GracePress-managed plugin entries so unrelated third-party MeshCentral plugins are not touched. + +## 0.0.16 - Legacy row quarantine +- Archives and removes obsolete legacy MeshCentral plugin rows such as `gracepress-theme-studio` after preserving their registry document under the live plugin `.old` folder. +- Preserves the installed/active status of the corrected `gpthemestudio` row during catalog sync instead of forcing managed rows back to disabled. +- Keeps Theme Studio migration in the rolling catalog update path while avoiding duplicate disabled rows in MeshCentral's plugin list. + +## 0.0.15 - MeshCentral-safe Theme Studio seed +- Updated the built-in Theme Studio seed to use the MeshCentral-safe runtime short name `gpthemestudio`. +- Preserved the public GracePress catalog slug `gracepress-theme-studio` while separating it from MeshCentral's admin-route identity. +- Keeps the catalog-manager rolling update path as the migration mechanism for correcting installed MeshCentral plugin rows. +- Migrates the legacy `gracepress-theme-studio` MeshCentral row forward to `gpthemestudio` instead of creating a duplicate row. + +## 0.0.14 - Built-in MeshCentral plugin catalog seed +- Added a deterministic built-in GracePress MeshCentral plugin catalog seed for `GracePress Theme Studio`. +- Changed discovery to write disabled plugin rows through MeshCentral's database helper directly instead of the higher-level plugin wrapper. +- Added explicit sync-stage logging so MeshCentral service logs show whether discovery starts, reads existing rows, inserts rows, or fails. +- Kept the dynamic catalog URL in metadata for a later pass, but removed runtime catalog fetch as a dependency for exposing the first managed plugin. + +## 0.0.13 - Constructor startup sync +- Moved GracePress MeshCentral plugin discovery into the plugin constructor path because MeshCentral does not call `server_startup()` for already-installed plugins during ordinary service reload. +- Kept the `server_startup()` hook for install/upgrade compatibility while also scheduling discovery shortly after plugin load. +- Added an admin-panel fallback that starts discovery if the connector panel opens before the scheduled sync has run. + +## 0.0.12 - Catalog plugin discovery +- Added startup sync from the GracePress `mcplugins` catalog bucket into MeshCentral's plugin registry. +- Missing GracePress MeshCentral plugins are now inserted as disabled/available rows, ready for MeshCentral's normal Install action. +- Existing disabled rows are refreshed from their current `.mcplugin.json` config while active installed plugins are left alone. +- Added backend panel sync status so catalog discovery can be inspected from the connector admin view. + +## 0.0.11 - Facade confirmation update +- Published a marker-only update to confirm the MeshCentral git facade shape after the successful `0.0.10` upgrade. +- Preserved the MeshCentral-facing `downloadUrl` and `versionHistoryUrl` facade URLs in package config. +- Updated the backend panel test marker to `0.0.11`. + +## 0.0.10 - MeshCentral git facade test +- Added a marker-only update that uses GracePress-generated MeshCentral git facade URLs. +- Switched MeshCentral `downloadUrl` to the no-query `gracepress.download.php` package facade. +- Switched MeshCentral `versionHistoryUrl` to `gracepress.tags.json`, shaped like a GitHub tags response with `name` and `url` fields. +- Updated the backend panel test marker to `0.0.10`. + +## 0.0.9 - Versioned download URL retest +- Published a marker-only update that uses the versioned package URL as MeshCentral `downloadUrl`. +- Avoided the cached `gracepress-latest.zip` alias and the nginx path-info download route while validating the built-in Upgrade action. +- Updated the backend panel test marker to `0.0.9`. + +## 0.0.8 - Upgrade flow retest +- Published a small marker-only update for validating MeshCentral's built-in Upgrade action after the MySQL plugin registry repair. +- Updated the backend panel test marker to `0.0.8` so a successful install can be confirmed from the MeshCentral plugin panel. + +## 0.0.7 - Direct MeshCentral latest zip +- Replaced the query-string latest-download endpoint with a stable `gracepress-latest.zip` file because MeshCentral's plugin downloader builds requests from `URL.pathname` and drops query strings. +- Kept versioned GracePress release artifacts while adding a MeshCentral-safe latest zip alias for the built-in Upgrade action. +- Updated package metadata so installed MeshCentral config rows keep using the stable latest zip URL after upgrade. + +## 0.0.6 - Stable MeshCentral upgrade download +- Added a GracePress `mcplugin-download` endpoint for MeshCentral's built-in Upgrade action. +- Changed the MeshCentral-facing `downloadUrl` to a stable latest-download endpoint because MeshCentral upgrades from the installed DB row, not the freshly fetched latest config object. +- Kept the GracePress manifest `download_url` versioned for catalog and rollback clarity. + +## 0.0.5 - Upgrade detection test +- Published a small catalog-only test update for MeshCentral's built-in Upgrade action. +- Added a visible backend panel note so successful upgrade can be confirmed after MeshCentral installs the package. + +## 0.0.4 - MeshCentral lifecycle alignment +- Renamed the display/package name to GracePress Catalog Connector while preserving the stable `gracepress` slug and MeshCentral `shortName`. +- Changed MeshCentral repository metadata to `git` so MeshCentral's built-in installer and upgrade action can download the GracePress-hosted zip. +- Documented the active plugin identity rule: folder name, `shortName`, JS export, config row, and catalog slug must line up. +- Prepared the live registry cleanup from duplicate `gracepress` rows into one active catalog-managed plugin. + +## 0.0.3 - Catalog-managed backend panel +- Moved the live GracePress MeshCentral dummy plugin into the GracePress workspace as an `mcplugin` package. +- Added internal MeshCentral plugin header metadata for GracePress catalog builds. +- Added backend admin panel catalog checks against the GracePress manifest endpoint. +- Updated MeshCentral plugin config URLs to use the GracePress catalog-managed package, config, and changelog artifacts. + +## 0.0.2 - Backend hello-world route +- Replaced device-tab test behavior with a backend/admin plugin panel. +- Kept MeshCentral plugin permissions unused while the current MeshCentral database adapter reports missing plugin-permission helpers. diff --git a/dist/current/gracepress.download.php b/dist/current/gracepress.download.php new file mode 100644 index 0000000..207c4b6 --- /dev/null +++ b/dist/current/gracepress.download.php @@ -0,0 +1,57 @@ + 0) { + ob_end_clean(); +} + +header('Access-Control-Allow-Origin: *'); +header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); +header('Pragma: no-cache'); +header('Content-Type: application/zip'); +header('Content-Disposition: attachment; filename="' . $zip . '"'); +header('Content-Length: ' . filesize($file)); +header('X-GracePress-Download-Url: ' . $download); + +if ($_SERVER['REQUEST_METHOD'] !== 'HEAD') { + readfile($file); +} \ No newline at end of file diff --git a/dist/current/gracepress.json b/dist/current/gracepress.json new file mode 100644 index 0000000..8580563 --- /dev/null +++ b/dist/current/gracepress.json @@ -0,0 +1,29 @@ +{ + "name": "GracePress Catalog Connector", + "slug": "gracepress", + "type": "mcplugin", + "category": "MeshCentral", + "version": "0.0.18", + "stage": "Alpha", + "release_channel": "Internal", + "public_release": "No", + "public_candidate": "No", + "wp_org_slug": "", + "description": "MeshCentral backend connector for GracePress catalog-managed plugin delivery, updates, and diagnostics.", + "icon": "🧩", + "requires_php": "8.0", + "changelog_url": "https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress", + "changelog_updated": "2026-06-27 18:52:52 UTC", + "download_url": "https://christit.com/gracepress/gracepress-v0.0.18.zip", + "file": "gracepress/gracepress.js", + "settings_slug": "gracepress", + "last_updated": "2026-06-27 18:53:57 UTC", + "meshcentral": { + "short_name": "gracepress", + "has_admin_panel": true, + "main": "gracepress.js", + "config_url": "https://christit.com/gracepress/gracepress.mcplugin.json" + }, + "meshcentral_config_url": "https://christit.com/gracepress/gracepress.mcplugin.json", + "install_type": "meshcentral-plugin" +} diff --git a/dist/current/gracepress.mcplugin.json b/dist/current/gracepress.mcplugin.json new file mode 100644 index 0000000..8bc6b6f --- /dev/null +++ b/dist/current/gracepress.mcplugin.json @@ -0,0 +1,26 @@ +{ + "name": "GracePress Catalog Connector", + "shortName": "gracepress", + "version": "0.0.18", + "description": "MeshCentral backend connector for GracePress catalog-managed plugin delivery, updates, and diagnostics.", + "author": "Jason Eugene Garrison", + "homepage": "https://christit.com", + "repository": { + "type": "git", + "url": "https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress" + }, + "hasAdminPanel": true, + "main": "gracepress.js", + "configUrl": "https://christit.com/gracepress/gracepress.mcplugin.json", + "downloadUrl": "https://christit.com/gracepress/gracepress.download.php", + "changelogUrl": "https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress", + "versionHistoryUrl": "https://christit.com/gracepress/gracepress.tags.json", + "meshCentralCompat": ">=1.1.0", + "download_url": "https://christit.com/gracepress/gracepress-v0.0.18.zip", + "gracepress": { + "slug": "gracepress", + "type": "mcplugin", + "built_at": "2026-06-27 18:53:57 UTC", + "manifest_url": "https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress" + } +} diff --git a/dist/current/gracepress.tags.json b/dist/current/gracepress.tags.json new file mode 100644 index 0000000..97a95cb --- /dev/null +++ b/dist/current/gracepress.tags.json @@ -0,0 +1,98 @@ +[ + { + "name": "0.0.18", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.18.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.18.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.18.zip" + }, + { + "name": "0.0.17", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.17.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.17.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.17.zip" + }, + { + "name": "0.0.16", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.16.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.16.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.16.zip" + }, + { + "name": "0.0.15", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.15.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.15.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.15.zip" + }, + { + "name": "0.0.14", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.14.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.14.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.14.zip" + }, + { + "name": "0.0.13", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.13.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.13.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.13.zip" + }, + { + "name": "0.0.12", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.12.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.12.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.12.zip" + }, + { + "name": "0.0.11", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.11.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.11.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.11.zip" + }, + { + "name": "0.0.10", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.10.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.10.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.10.zip" + }, + { + "name": "0.0.9", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.9.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.9.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.9.zip" + }, + { + "name": "0.0.8", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.8.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.8.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.8.zip" + }, + { + "name": "0.0.7", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.7.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.7.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.7.zip" + }, + { + "name": "0.0.6", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.6.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.6.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.6.zip" + }, + { + "name": "0.0.5", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.5.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.5.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.5.zip" + }, + { + "name": "0.0.4", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.4.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.4.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.4.zip" + }, + { + "name": "0.0.3", + "zipball_url": "https://christit.com/gracepress/gracepress-v0.0.3.zip", + "tarball_url": "https://christit.com/gracepress/gracepress-v0.0.3.zip", + "url": "https://christit.com/gracepress/gracepress-v0.0.3.zip" + } +] diff --git a/dist/current/mesh-press.changelog.md b/dist/current/mesh-press.changelog.md new file mode 100644 index 0000000..d12f6dc --- /dev/null +++ b/dist/current/mesh-press.changelog.md @@ -0,0 +1,163 @@ +# GracePress: MeshPress Sync Changelog + +## Current +- Added a new-server onboarding wizard for bridge URL, token, public MeshCentral URL, advanced mode, and first health checks. +- Added a connection health panel for WordPress-to-bridge, bridge-to-MeshCentral, public URL, fleet freshness, bridge push, and OmniLog event flow. +- Added an advanced-mode gate for MeshCentral Ops and raw troubleshooting controls. +- Added an agent enrollment check to confirm newly installed agents appear in the selected MeshCentral group. +- Added a protected bridge event receiver so MeshPress Bridge lifecycle events can flow into OmniLog. + +## 1.13.0 - 2026-06-12 +- Added a new-server onboarding wizard for bridge URL, token, public MeshCentral URL, advanced mode, and first health checks. +- Added a connection health panel for WordPress-to-bridge, bridge-to-MeshCentral, public URL, fleet freshness, bridge push, and OmniLog event flow. +- Added an advanced-mode gate for MeshCentral Ops and raw troubleshooting controls. +- Added an agent enrollment check to confirm newly installed agents appear in the selected MeshCentral group. +- Added a protected bridge event receiver so MeshPress Bridge lifecycle events can flow into OmniLog. + +## 1.12.7 - 2026-05-25 +- Added separate controls for bridge maintenance and real MeshCentral maintenance mode. +- Added redacted MeshCentral config audit output for safer copy/paste diagnostics. +- Upgraded backups to request full MeshCentral snapshots, including MySQL/MariaDB dumps when configured. + +## 1.12.6 - 2026-05-25 +- Rolled MeshPress admin back to the last known stable 1.12.1 layout after later rescue edits broke tabs, buttons, and fleet loading. +- Preserved MeshCentral `@` characters in group/device identifiers before sending requests to the bridge. +- Removed browser dark-mode override CSS that inverted MeshPress cards in light mode. +- Restored explicit MeshCentral maintenance-mode enable/disable controls now that the bridge endpoint is stable again. +- Simplified agent provisioning into one primary Linux install command plus a compact installer/link picker. +- Added clearer Mesh ID diagnostics without forcing operators to manually paste or understand the raw MeshCentral group id. + +## 1.12.5 - 2026-05-24 +- Rolled MeshPress admin back to the last known stable 1.12.1 layout after later rescue edits broke tabs, buttons, and fleet loading. +- Preserved MeshCentral `@` characters in group/device identifiers before sending requests to the bridge. +- Removed browser dark-mode override CSS that inverted MeshPress cards in light mode. + +## 1.12.0 - 2026-05-24 +- Added a MeshCentral Ops tab for runtime/update checks, guarded backups, and agent uninstall diagnostics through the bridge. +- Added an operator-only uninstall-agent test button to the device troubleshooting flow. +- Added admin output panels designed for copyable diagnostics when bridge maintenance or MeshCentral commands fail. + +## 1.11.9 - 2026-05-24 +- Improved the fleet overview with grouped device cards, search, group filtering, status filtering, and direct Desktop/Terminal/Files actions. +- Expanded agent deployment output for Windows, macOS, Linux, Mesh Assistant, and copyable Linux install commands. +- Added clearer provisioning guidance and bridge diagnostic output for group edit failures. + +## 1.11.8 - 2026-05-24 +- Routed fleet Desktop/Console/Files buttons through bridge-generated session links instead of locally guessed URLs. +- Preserved MeshCentral `$` node ids through AJAX sanitization and made `viewmode-first + gotonode` the tested primary path. +- Added session diagnostics plus manual group edit, device rename, and device move controls for bridge testing. + +## 1.11.7 - 2026-05-23 +- Added bridge runtime version/status visibility and manual update triggering from MeshPress admin. +- Added a richer Desktop/Console/Files session link tester with multiple MeshCentral direct-link candidates. +- Cleaned mojibake labels from fleet action buttons. + +## 1.11.6 - 2026-05-23 +- Added Bridge Runtime Status controls for version checks and manual bridge updates. +- Added support for bridge `/api/session-links` and console link candidate testing. +- Renamed Terminal actions to Console in the UI to match the workflow being tested. + +## 1.11.5 - 2026-05-23 +- Added a Diagnostics & Manual Actions tab for bridge probing and first-party MeshCentral validation. +- Added manual MeshCentral group creation from MeshPress, with clear output and generated agent links. +- Added read-only bridge probes for health, status, self-test, groups, devices, users, events, server info, and device info. +- Added direct session link testing for Desktop, Terminal, and Files in both `gotonode` and `node` URL formats. + + + +## Reconstructed History + +These entries were inferred from local distribution archives, module headers, and `.old` backups. They are intentionally conservative and may not capture the full intent of each change. + +### 1.11.4 - reconstructed +- Reconstructed from archive `mesh-press-v1.11.4.zip` (2026-05-21). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Header advertised WordPress compatibility through 7.0. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.11.3 - reconstructed +- Reconstructed from archive `mesh-press-v1.11.3.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.11.2 - reconstructed +- Reconstructed from archive `mesh-press-v1.11.2.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.11.1 - reconstructed +- Reconstructed from archive `mesh-press-v1.11.1.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.11.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.11.0.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.10.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.10.0.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.9.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.9.0.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.8.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.8.0.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.7.1 - reconstructed +- Reconstructed from archive `mesh-press-v1.7.1.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects to the custom MeshCentral GracePress API plugin for flawless fleet syncing and management. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.7.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.7.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects to the custom MeshCentral GracePress API plugin for flawless fleet syncing and management. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.5.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.5.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Uses a secure Cookie-Based REST API bridge to sync your MeshCentral fleet into WordPress. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.4.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.4.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Uses a native WebSocket payload engine to sync your MeshCentral fleet into WordPress. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.3.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.3.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Uses a native WebSocket engine to sync your MeshCentral fleet into WordPress. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.1.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.1.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Syncs your MeshCentral fleet into WordPress for instant remote desktops and API-driven automation. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.0.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.0.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Syncs your MeshCentral fleet into WordPress for instant remote desktops and API-driven automation. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + + diff --git a/dist/current/mesh-press.json b/dist/current/mesh-press.json new file mode 100644 index 0000000..f2d2caa --- /dev/null +++ b/dist/current/mesh-press.json @@ -0,0 +1,17 @@ +{ + "name": "GracePress: MeshPress Sync", + "slug": "mesh-press", + "type": "plugin", + "category": "Utility", + "version": "1.13.0", + "stage": "Production", + "description": "Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows.", + "icon": "🌐", + "requires_php": "8.0", + "changelog_url": "https://christit.com/gracepress/api.php?endpoint=changelog&slug=mesh-press", + "changelog_updated": "2026-06-12 19:28:43 UTC", + "download_url": "https://christit.com/gracepress/mesh-press-v1.13.0.zip", + "file": "mesh-press/mesh-press.php", + "settings_slug": "mesh-press", + "last_updated": "2026-06-12 19:29:09 UTC" +} diff --git a/dist/current/meshpress-bridge.changelog.md b/dist/current/meshpress-bridge.changelog.md new file mode 100644 index 0000000..eca85c6 --- /dev/null +++ b/dist/current/meshpress-bridge.changelog.md @@ -0,0 +1,109 @@ +# meshpress-bridge Changelog + +## Current +- Fixed agent-link generation to pass the resolved group object instead of a boolean match flag. +- Added selective WordPress event pushes for high-signal bridge lifecycle events so MeshPress can forward them into OmniLog. +- Added bridge status visibility for WordPress event push configuration. + +## 0.4.3 - 2026-06-12 +- Fixed agent-link generation to pass the resolved group object instead of a boolean match flag. +- Added selective WordPress event pushes for high-signal bridge lifecycle events so MeshPress can forward them into OmniLog. +- Added bridge status visibility for WordPress event push configuration. + +## 0.4.2 - 2026-05-25 +- Added redacted MeshCentral config auditing for MySQL/MariaDB, framing, logging, backup, and maintenance posture. +- Added real MeshCentral `settings.maintenanceMode` toggling with config backup and service restart support. +- Replaced the data-only backup with a guarded full snapshot that includes MeshCentral files, config, manifest, checksum, and a MySQL/MariaDB dump when configured. +- Added a configurable database dump timeout (`MESHPRESS_DB_DUMP_TIMEOUT_SECONDS`) so backup failures produce clear diagnostics instead of hanging. + +## 0.4.1 - 2026-05-25 +- Resolved MeshCentral groups before generating agent links, so WordPress punctuation drift no longer breaks installer commands. +- Generated Linux install commands from the canonical group short id, including MeshCentral `@` characters. +- Returned the matched group record and group name with agent-link responses for clearer MeshPress output. +- Returned normalized group `short_id` values so agent-link generation can preserve MeshCentral punctuation across the full path. +- Added installer-shape diagnostics and an explicit warning when the resolved short Mesh ID looks too short. +- Added a curl fallback command while keeping the native MeshCentral wget installer command as the primary Linux path. + +## 0.4.0 - 2026-05-24 +- Resolved MeshCentral groups before generating agent links, so WordPress punctuation drift no longer breaks installer commands. +- Generated Linux install commands from the canonical group short id, including MeshCentral `@` characters. +- Returned the matched group record and group name with agent-link responses for clearer MeshPress output. + +## 0.3.9 - 2026-05-24 +- Replaced the bridge updater with a staged, verified, rollback-capable update flow that does not delete PM2 before the new copy is ready. +- Added catalog update checks and a runtime maintenance-mode toggle endpoint for MeshPress. +- Made group-id resolution tolerant of MeshCentral punctuation drift, and switched Linux agent commands to MeshCentral's native install-script pattern. + +## 0.3.8 - 2026-05-24 +- Group edits now verify the refreshed MeshCentral group record before returning success. +- Added extra group edit diagnostics when MeshCtrl accepts a command but the group does not actually change. +- Tried both `--desc` and `--description` edit forms for better MeshCtrl version compatibility. + +## 0.3.7 - 2026-05-24 +- Added guarded MeshCentral maintenance endpoints for version/update checks, data backups, and update triggering. +- Added agent uninstall diagnostics and richer copy-friendly bridge logging for lifecycle testing. +- Hardened the bridge installer/updater so running from the live `/opt` target refuses instead of deleting itself. + +## 0.3.6 - 2026-05-24 +- Rejected MeshCtrl failure text even when MeshCtrl exits with code 0, preventing false-success group edit responses. +- Added richer group edit candidate attempts and copyable JSON diagnostics for invalid group identifiers. +- Expanded agent link generation with Windows, Linux, macOS, Mesh Assistant, and Linux install command output. + +## 0.3.5 - 2026-05-24 +- Changed MeshCentral session links to prefer the working `viewmode-first + gotonode` URL pattern. +- Added short-node-id extraction for `gotonode` links so full `node//...` ids do not break direct console sessions. +- Added `/api/session-diagnostics/:nodeId` plus guarded device rename and move endpoints for manual MeshPress testing. + +## 0.3.4 - 2026-05-23 +- Added bridge runtime version reporting and a guarded manual update endpoint for MeshPress admin. +- Added richer MeshCentral session-link generation for Desktop, Console, and Files with multiple direct-link candidates. +- Added `console` and `shell` aliases for MeshCentral terminal sessions. + +## 0.3.3 - 2026-05-23 +- Added `/api/version`, `/api/update-now`, and `/api/session-links/:nodeId`. +- Added all-link candidate generation for MeshCentral session testing. +- Restarted bridge updates with `pm2 --update-env` so env/config changes are picked up. + +## 0.3.2 - 2026-05-23 +- Hardened bridge authentication for public-port exposure by requiring a configured bearer token on all endpoints. +- Added optional exact-IP allowlisting and failed-auth rate limiting. +- Moved WordPress fleet push authentication from query string to Authorization header. +- Added startup security warnings for all-interface binding without app-layer allowlisting. + +## 0.3.1 - 2026-05-23 +- Added bridge self-test, server-info, users, events, and device-info diagnostics for MeshPress. +- Added configurable MeshCentral direct-link style with `MESHPRESS_LINK_MODE`. +- Added guarded provisioning endpoints for group edit/remove, group user assignment, invite links, and simple device message/toast commands. +- Added cache clearing and richer status output for faster troubleshooting. + +## 0.3.0 - 2026-05-22 +- Added Phase 5.2 MeshCentral provisioning endpoints for Service Desk. +- Added authenticated agent/provisioning link generation for MeshCentral device groups. +- Added guarded device group creation through MeshCtrl `adddevicegroup`. +- Required `MESHPRESS_ALLOW_COMMANDS=1` before the bridge will create MeshCentral groups, keeping provisioning actions opt-in. +- Returned Windows x64, x86, ARM64, and interactive Windows agent links for linked MeshCentral groups. + + + +## Reconstructed History + +These entries were inferred from local distribution archives, module headers, and `.old` backups. They are intentionally conservative and may not capture the full intent of each change. + +### 0.2.0 - reconstructed +- Reconstructed from archive `meshpress-bridge-v0.2.0.zip` (2026-05-20). +- Header description at this version: GracePress MeshPress Bridge. Pushes MeshCentral fleet snapshots to WordPress MeshPress. +- Catalog/category marker: Hidden. + +### 0.1.1 - reconstructed +- Reconstructed from 2 file backup(s) in `tools/meshpress-bridge/.old`. +- Exact intent unknown; this confirms a recoverable historical version existed. + +### 0.1.0 - reconstructed +- Reconstructed from 1 file backup(s) in `tools/meshpress-bridge/.old`. +- Exact intent unknown; this confirms a recoverable historical version existed. + +### VERSION, - reconstructed +- Reconstructed from 2 file backup(s) in `tools/meshpress-bridge/.old`. +- Exact intent unknown; this confirms a recoverable historical version existed. + + diff --git a/dist/current/meshpress-bridge.json b/dist/current/meshpress-bridge.json new file mode 100644 index 0000000..63bf39c --- /dev/null +++ b/dist/current/meshpress-bridge.json @@ -0,0 +1,17 @@ +{ + "name": "meshpress-bridge", + "slug": "meshpress-bridge", + "type": "tool", + "category": "Hidden", + "version": "0.4.3", + "stage": "Production", + "description": "GracePress MeshPress Bridge. Pushes MeshCentral fleet snapshots to WordPress MeshPress.", + "icon": "⚙️", + "requires_php": "7.4", + "changelog_url": "https://christit.com/gracepress/api.php?endpoint=changelog&slug=meshpress-bridge", + "changelog_updated": "2026-06-12 19:28:43 UTC", + "download_url": "https://christit.com/gracepress/meshpress-bridge-v0.4.3.zip", + "file": "meshpress-bridge/meshpress-bridge", + "settings_slug": "", + "last_updated": "2026-06-12 19:29:32 UTC" +} diff --git a/docs/workspace-map.md b/docs/workspace-map.md new file mode 100644 index 0000000..6569a53 --- /dev/null +++ b/docs/workspace-map.md @@ -0,0 +1,26 @@ +# Workspace Map + +## Imported Source + +| Area | New Path | Original GracePress Path | +| --- | --- | --- | +| WordPress plugin | `wordpress/plugins/mesh-press/` | `plugins/mesh-press/` | +| Bridge service | `bridge/meshpress-bridge/` | `tools/meshpress-bridge/` | +| Catalog connector | `meshcentral/plugins/gracepress/` | `meshcentral-plugins/gracepress/` | +| Theme Studio plugin | `meshcentral/plugins/gracepress-theme-studio/` | `meshcentral-plugins/gracepress-theme-studio/` | +| Current artifacts | `dist/current/` | `dist/` selected MeshPress and MeshCentral artifacts | + +## Boundaries + +- WordPress plugin code belongs under `wordpress/plugins/`. +- Node helper services belong under `bridge/`. +- MeshCentral runtime plugins belong under `meshcentral/plugins/`. +- Generated release evidence belongs under `dist/current/` until dedicated build output rules exist. +- Upstream MeshCentral source belongs outside the tracked repository by default. + +## Follow-Up Work + +- Port or rewrite the GracePress `mcplugin` build flow so this workspace can package MeshCentral plugins independently. +- Decide whether MeshPress should remain catalog-released through GracePress or gain a separate release channel. +- Normalize mojibake metadata in imported manifests and headers during the first real source change. +- Add smoke checks for the bridge service and MeshCentral plugin identity rules. diff --git a/meshcentral/plugins/gracepress-theme-studio/CHANGELOG.md b/meshcentral/plugins/gracepress-theme-studio/CHANGELOG.md new file mode 100644 index 0000000..bafa0a9 --- /dev/null +++ b/meshcentral/plugins/gracepress-theme-studio/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 0.0.3 - Profile editor foundation +- Added Theme Studio profile storage with a locked MeshCentral Default profile and editable cloned theme profiles. +- Added profile dropdown selection, clone current profile, save profile, and export JSON actions. +- Replaced the raw inventory-first screen with grouped Brand, Colors, Typography, Layout, Icons, and Advanced controls. +- Added dropdown/select controls for fonts, density, icon set, icon size, spacing-style values, and standard color pickers for color fields. +- Kept Apply and startup CSS hook controls disabled until generated CSS, rollback, and service-load behavior are implemented. + +## 0.0.2 - MeshCentral-safe runtime identity +- Changed the MeshCentral runtime `shortName` to `gpthemestudio` so MeshCentral's alphanumeric admin route guard can open the backend panel. +- Renamed the runtime entrypoint to `gpthemestudio.js` and aligned the JavaScript export with the MeshCentral loader. +- Preserved the GracePress catalog slug `gracepress-theme-studio` and the existing catalog facade URLs. + +## 0.0.1 - Default theme inventory +- Created the initial GracePress Theme Studio MeshCentral plugin. +- Captured the current classic MeshCentral UI as a locked `MeshCentral Default` theme profile. +- Added a backend admin panel for reviewing theme variables and future theming capabilities. +- Kept this first release read-only; it does not inject CSS, alter templates, or modify MeshCentral core files. diff --git a/meshcentral/plugins/gracepress-theme-studio/config.json b/meshcentral/plugins/gracepress-theme-studio/config.json new file mode 100644 index 0000000..c3f4653 --- /dev/null +++ b/meshcentral/plugins/gracepress-theme-studio/config.json @@ -0,0 +1,19 @@ +{ + "name": "GracePress Theme Studio", + "shortName": "gpthemestudio", + "version": "0.0.3", + "description": "MeshCentral UI theme profile editor for GracePress-managed MeshCentral servers.", + "author": "Jason Eugene Garrison", + "homepage": "https://christit.com", + "repository": { + "type": "git", + "url": "https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress-theme-studio" + }, + "hasAdminPanel": true, + "main": "gpthemestudio.js", + "configUrl": "https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json", + "downloadUrl": "https://christit.com/gracepress/gracepress-theme-studio.download.php", + "changelogUrl": "https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress-theme-studio", + "versionHistoryUrl": "https://christit.com/gracepress/gracepress-theme-studio.tags.json", + "meshCentralCompat": ">=1.1.0" +} diff --git a/meshcentral/plugins/gracepress-theme-studio/default-theme.json b/meshcentral/plugins/gracepress-theme-studio/default-theme.json new file mode 100644 index 0000000..2d56588 --- /dev/null +++ b/meshcentral/plugins/gracepress-theme-studio/default-theme.json @@ -0,0 +1,73 @@ +{ + "schema": 1, + "id": "meshcentral-default", + "name": "MeshCentral Default", + "description": "Captured baseline for the current Christ IT Connections MeshCentral classic UI before GracePress theming is applied.", + "locked": true, + "capturedAt": "2026-06-27", + "source": "GracePress Theme Studio initial default profile", + "variables": { + "brand.title": "Christ IT Connections", + "brand.subtitle": "mesh.christit.com", + "brand.logoUrl": "", + "brand.footerText": "Terms & Privacy", + "font.family": "Arial, Helvetica, sans-serif", + "font.baseSize": "16px", + "font.smallSize": "12px", + "font.headingSize": "32px", + "layout.mode": "classic-leftbar", + "layout.density": "default", + "layout.contentMaxWidth": "none", + "layout.leftbarWidth": "75px", + "layout.iconRailWidth": "75px", + "layout.footerHeight": "48px", + "layout.borderRadius": "0px", + "color.pageBackground": "#000000", + "color.headerBackground": "#003a70", + "color.headerText": "#d7d7d7", + "color.headerAccent": "#ffffff", + "color.leftbarBackground": "#1f1f2a", + "color.leftbarActiveBackground": "#8bc8ff", + "color.leftbarActiveBorder": "#66c27a", + "color.sidebarBackground": "#144c83", + "color.sidebarButtonBackground": "#06417a", + "color.sidebarButtonActiveBackground": "#0d5ea8", + "color.tabBackground": "#8b8b8b", + "color.tabActiveBackground": "#004b8d", + "color.tabText": "#ffffff", + "color.panelBackground": "#000000", + "color.panelText": "#d7d7d7", + "color.tableHeaderText": "#ffffff", + "color.tableRowText": "#d7d7d7", + "color.link": "#5dade2", + "color.success": "#00cc44", + "color.warning": "#b45309", + "color.danger": "#cc3333", + "color.modalOverlay": "rgba(0,0,0,0.5)", + "color.modalBackground": "#ffffff", + "color.modalText": "#111111", + "color.modalBorder": "#d9dde5", + "button.primaryBackground": "#007bff", + "button.primaryText": "#ffffff", + "button.secondaryBackground": "#f4f4f6", + "button.secondaryText": "#111111", + "button.border": "#aeb4bf", + "table.border": "#303030", + "table.headerBackground": "#000000", + "table.rowBackground": "#000000", + "table.rowAltBackground": "#050505", + "icons.set": "meshcentral-classic", + "icons.size": "48px", + "icons.monochrome": false, + "icons.packPath": "", + "effects.textShadow": "2px 2px 3px rgba(0,0,0,0.85)", + "effects.panelShadow": "none", + "effects.modalShadow": "0 0 24px rgba(0,0,0,0.6)", + "advanced.customCss": "" + }, + "notes": [ + "This profile is intentionally descriptive. It records the current classic UI appearance as variables before any global CSS injection exists.", + "Values can be refined from MeshCentral CSS inspection in a later pass.", + "Do not mutate this profile in-place; clone it into a new theme when applying changes." + ] +} diff --git a/meshcentral/plugins/gracepress-theme-studio/gpthemestudio.js b/meshcentral/plugins/gracepress-theme-studio/gpthemestudio.js new file mode 100644 index 0000000..5d2cd17 --- /dev/null +++ b/meshcentral/plugins/gracepress-theme-studio/gpthemestudio.js @@ -0,0 +1,685 @@ +'use strict'; + +/** + * Plugin Name: GracePress Theme Studio + * Description: MeshCentral UI theme inventory and profile editor for GracePress-managed MeshCentral servers. + * Version: 0.0.3 + * Stage: Alpha + * Release Channel: Internal + * Public Release: No + * Public Candidate: No + * Category: MeshCentral + * Settings Slug: gracepress-theme-studio + * WordPress.org Slug: + * MeshCentral Short Name: gpthemestudio + * MeshCentral Config URL: https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json + * MeshCentral Compatibility: >=1.1.0 + * Requires PHP: 8.0 + * Icon: 🧩 + */ + +const fs = require('fs'); +const path = require('path'); +const querystring = require('querystring'); + +const PLUGIN = { + name: 'GracePress Theme Studio', + shortName: 'gpthemestudio', + version: '0.0.3', +}; + +const PROFILE_DIR = 'themes'; +const DEFAULT_PROFILE_ID = 'meshcentral-default'; +const ACTIVE_FILE = 'active-theme.json'; + +const CONTROL_GROUPS = [ + { + id: 'brand', + label: 'Brand', + description: 'Visible identity and non-CSS presentation values.', + fields: [ + { key: 'brand.title', label: 'Site title', type: 'text' }, + { key: 'brand.subtitle', label: 'Subtitle', type: 'text' }, + { key: 'brand.logoUrl', label: 'Logo URL', type: 'text', placeholder: 'https://...' }, + { key: 'brand.footerText', label: 'Footer text', type: 'text', placeholder: 'Terms & Privacy' }, + ], + }, + { + id: 'color', + label: 'Colors', + description: 'Primary MeshCentral chrome, panel, link, and state colors.', + fields: [ + { key: 'color.headerBackground', label: 'Header background', type: 'color' }, + { key: 'color.headerText', label: 'Header text', type: 'color' }, + { key: 'color.leftbarBackground', label: 'Icon rail background', type: 'color' }, + { key: 'color.sidebarBackground', label: 'Sidebar background', type: 'color' }, + { key: 'color.tabActiveBackground', label: 'Active tab', type: 'color' }, + { key: 'color.panelBackground', label: 'Panel background', type: 'color' }, + { key: 'color.panelText', label: 'Panel text', type: 'color' }, + { key: 'color.link', label: 'Links', type: 'color' }, + { key: 'color.success', label: 'Success', type: 'color' }, + { key: 'color.warning', label: 'Warning', type: 'color' }, + { key: 'color.danger', label: 'Danger', type: 'color' }, + ], + }, + { + id: 'font', + label: 'Typography', + description: 'Dropdown-driven font decisions instead of blank freehand values.', + fields: [ + { + key: 'font.family', + label: 'Font family', + type: 'select', + options: [ + 'Arial, Helvetica, sans-serif', + 'Inter, Arial, Helvetica, sans-serif', + 'Segoe UI, Arial, Helvetica, sans-serif', + 'Roboto, Arial, Helvetica, sans-serif', + 'Georgia, serif', + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + ], + }, + { key: 'font.baseSize', label: 'Base size', type: 'select', options: ['14px', '15px', '16px', '17px', '18px'] }, + { key: 'font.headingSize', label: 'Heading size', type: 'select', options: ['24px', '28px', '32px', '36px', '40px'] }, + ], + }, + { + id: 'layout', + label: 'Layout', + description: 'Stable layout tokens for later CSS generation.', + fields: [ + { key: 'layout.density', label: 'Density', type: 'select', options: ['compact', 'default', 'comfortable'] }, + { key: 'layout.leftbarWidth', label: 'Icon rail width', type: 'select', options: ['60px', '68px', '75px', '84px', '96px'] }, + { key: 'layout.borderRadius', label: 'Border radius', type: 'select', options: ['0px', '3px', '5px', '8px'] }, + ], + }, + { + id: 'icons', + label: 'Icons', + description: 'Icon pack selection. Custom pack upload/generation comes after the mapping is proven.', + fields: [ + { key: 'icons.set', label: 'Icon set', type: 'select', options: ['meshcentral-classic', 'gracepress-classic', 'gracepress-flat', 'custom-pack'] }, + { key: 'icons.size', label: 'Icon size', type: 'select', options: ['40px', '48px', '56px', '64px'] }, + { key: 'icons.packPath', label: 'Custom pack path', type: 'text', placeholder: '/path/to/icons' }, + ], + }, + { + id: 'advanced', + label: 'Advanced', + description: 'Recorded but not globally injected until rollback and startup loading are built.', + fields: [ + { key: 'advanced.customCss', label: 'Custom CSS notes', type: 'textarea' }, + ], + }, +]; + +module.exports.gpthemestudio = function(parent) { + const obj = { parent }; + obj.exports = []; + + ensureProfileStore(parent); + console.log(`${PLUGIN.name} loaded.`); + + obj.handleAdminReq = function(req, res, user) { + handleAdminRequest(parent, req, res, user); + }; + + return obj; +}; + +function handleAdminRequest(parent, req, res, user) { + if (req && req.method === 'POST') { + readBody(req, function(err, body) { + const username = getUsername(user); + let notice = ''; + let error = ''; + + if (err) { + error = err.message; + } else { + const result = handleAction(parent, body); + notice = result.notice || ''; + error = result.error || ''; + } + + const state = loadState(parent); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderAdmin(username, state, notice, error)); + }); + return; + } + + const state = loadState(parent, req); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderAdmin(getUsername(user), state, '', '')); +} + +function readBody(req, done) { + let body = ''; + req.on('data', function(chunk) { + body += chunk; + if (body.length > 262144) req.destroy(new Error('Request body too large.')); + }); + req.on('end', function() { + try { + done(null, querystring.parse(body)); + } catch (err) { + done(err); + } + }); + req.on('error', done); +} + +function handleAction(parent, data) { + const action = getPostedValue(data.action); + if (action === 'clone') return cloneProfile(parent, data); + if (action === 'save') return saveProfile(parent, data); + if (action === 'select') return selectActiveProfile(parent, data.profileId); + if (action === 'export') return exportProfile(parent, data.profileId); + return { error: 'Unknown Theme Studio action.' }; +} + +function cloneProfile(parent, data) { + const base = getProfileById(parent, String(data.sourceProfileId || DEFAULT_PROFILE_ID)) || loadDefaultTheme(parent); + const name = cleanProfileName(data.newProfileName || 'Christ IT Theme'); + const id = uniqueProfileId(parent, slugify(name)); + const clone = JSON.parse(JSON.stringify(base)); + + clone.id = id; + clone.name = name; + clone.locked = false; + clone.capturedAt = new Date().toISOString().slice(0, 10); + clone.source = `Cloned from ${base.name || base.id || DEFAULT_PROFILE_ID}`; + clone.description = `Editable theme profile cloned from ${base.name || 'MeshCentral Default'}.`; + clone.notes = ['Editable GracePress Theme Studio profile.']; + clone.branding = clone.branding || {}; + clone.generated = clone.generated || {}; + + writeProfile(parent, clone); + writeActiveProfile(parent, id); + return { notice: `Created editable theme profile "${name}".` }; +} + +function saveProfile(parent, data) { + const profileId = String(data.profileId || ''); + const profile = getProfileById(parent, profileId); + if (!profile) return { error: 'Selected profile could not be found.' }; + if (profile.locked) return { error: 'The default MeshCentral profile is locked. Clone it before editing.' }; + + profile.name = cleanProfileName(data.profileName || profile.name || profile.id); + profile.description = String(data.profileDescription || profile.description || ''); + profile.updatedAt = new Date().toISOString(); + profile.variables = profile.variables || {}; + + getEditableFields().forEach(function(field) { + const formKey = formFieldName(field.key); + if (Object.prototype.hasOwnProperty.call(data, formKey)) { + profile.variables[field.key] = normalizeFieldValue(field, data[formKey]); + } + }); + + writeProfile(parent, profile); + writeActiveProfile(parent, profile.id); + return { notice: `Saved "${profile.name}".` }; +} + +function selectActiveProfile(parent, profileId) { + const profile = getProfileById(parent, String(profileId || '')); + if (!profile) return { error: 'Selected profile could not be found.' }; + writeActiveProfile(parent, profile.id); + return { notice: `Selected "${profile.name}" as the active editing profile.` }; +} + +function exportProfile(parent, profileId) { + const profile = getProfileById(parent, String(profileId || '')); + if (!profile) return { error: 'Selected profile could not be found.' }; + const exportDir = path.join(getProfileDir(parent), 'exports'); + fs.mkdirSync(exportDir, { recursive: true }); + const file = path.join(exportDir, `${safeFileName(profile.id)}-${Date.now()}.json`); + fs.writeFileSync(file, JSON.stringify(profile, null, 2) + '\n', 'utf8'); + return { notice: `Exported "${profile.name}" to ${file}.` }; +} + +function ensureProfileStore(parent) { + const dir = getProfileDir(parent); + fs.mkdirSync(dir, { recursive: true }); + const defaultFile = path.join(dir, `${DEFAULT_PROFILE_ID}.json`); + if (!fs.existsSync(defaultFile)) { + writeProfile(parent, loadDefaultTheme(parent)); + } + if (!fs.existsSync(path.join(dir, ACTIVE_FILE))) { + writeActiveProfile(parent, DEFAULT_PROFILE_ID); + } +} + +function loadState(parent, req) { + ensureProfileStore(parent); + const profiles = loadProfiles(parent); + const requestedId = getRequestProfileId(req); + const activeId = requestedId || readActiveProfile(parent) || DEFAULT_PROFILE_ID; + const active = profiles.find(function(profile) { return profile.id === activeId; }) || profiles[0] || loadDefaultTheme(parent); + return { + profiles, + active, + activeId: active.id, + storagePath: getProfileDir(parent), + }; +} + +function getRequestProfileId(req) { + if (!req || !req.url || req.url.indexOf('?') === -1) return ''; + const query = querystring.parse(req.url.split('?').slice(1).join('?')); + return String(query.profileId || ''); +} + +function loadProfiles(parent) { + const dir = getProfileDir(parent); + const files = fs.readdirSync(dir) + .filter(function(file) { return file.endsWith('.json') && file !== ACTIVE_FILE; }) + .sort(); + const profiles = files.map(function(file) { + try { + return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); + } catch (err) { + return null; + } + }).filter(Boolean); + + profiles.sort(function(left, right) { + if (left.id === DEFAULT_PROFILE_ID) return -1; + if (right.id === DEFAULT_PROFILE_ID) return 1; + return String(left.name || left.id).localeCompare(String(right.name || right.id)); + }); + + return profiles.length ? profiles : [loadDefaultTheme(parent)]; +} + +function getProfileById(parent, profileId) { + return loadProfiles(parent).find(function(profile) { return profile.id === profileId; }) || null; +} + +function getProfileDir(parent) { + return path.join(__dirname, PROFILE_DIR); +} + +function readActiveProfile(parent) { + try { + return JSON.parse(fs.readFileSync(path.join(getProfileDir(parent), ACTIVE_FILE), 'utf8')).activeProfileId || DEFAULT_PROFILE_ID; + } catch (err) { + return DEFAULT_PROFILE_ID; + } +} + +function writeActiveProfile(parent, profileId) { + fs.writeFileSync(path.join(getProfileDir(parent), ACTIVE_FILE), JSON.stringify({ + activeProfileId: profileId || DEFAULT_PROFILE_ID, + selectedAt: new Date().toISOString(), + applied: false, + }, null, 2) + '\n', 'utf8'); +} + +function writeProfile(parent, profile) { + fs.mkdirSync(getProfileDir(parent), { recursive: true }); + fs.writeFileSync(path.join(getProfileDir(parent), `${safeFileName(profile.id)}.json`), JSON.stringify(profile, null, 2) + '\n', 'utf8'); +} + +function loadDefaultTheme(parent) { + const candidates = [ + path.join(__dirname, 'default-theme.json'), + ].filter(Boolean); + + for (const file of candidates) { + try { + if (fs.existsSync(file)) { + const theme = JSON.parse(fs.readFileSync(file, 'utf8')); + theme.id = DEFAULT_PROFILE_ID; + theme.name = theme.name || 'MeshCentral Default'; + theme.locked = true; + theme.variables = withProfileDefaults(theme.variables || {}); + return theme; + } + } catch (err) { + return fallbackDefaultTheme(file, err.message); + } + } + + return fallbackDefaultTheme('', ''); +} + +function fallbackDefaultTheme(source, error) { + return { + schema: 1, + id: DEFAULT_PROFILE_ID, + name: 'MeshCentral Default', + description: error ? 'Default theme profile could not be loaded.' : 'Default theme profile is missing from this plugin package.', + locked: true, + capturedAt: '', + source, + variables: withProfileDefaults({}), + error, + }; +} + +function withProfileDefaults(variables) { + const defaults = { + 'brand.logoUrl': '', + 'brand.footerText': 'Terms & Privacy', + 'icons.packPath': '', + 'advanced.customCss': '', + }; + return Object.assign({}, defaults, variables); +} + +function renderAdmin(username, state, notice, error) { + const profile = state.active || loadDefaultTheme(); + const variables = withProfileDefaults(profile.variables || {}); + const locked = !!profile.locked; + const profiles = state.profiles || []; + const rawGroups = groupVariables(variables); + const rawRows = renderRawInventory(rawGroups); + + return ` + + + + ${escapeHtml(PLUGIN.name)} + + + +
+
+

${escapeHtml(PLUGIN.name)}

+
Profile editor baseline for MeshCentral UI theming. Logged in as ${escapeHtml(username)}.
+
+ + ${notice ? `
${escapeHtml(notice)}
` : ''} + ${error ? `
${escapeHtml(error)}
` : ''} + +
+
Plugin
${escapeHtml(PLUGIN.version)}
+
Profile
${escapeHtml(profile.name || '')}
+
Variables
${Object.keys(variables).length}
+
Mode
${locked ? 'Locked default' : 'Editable'}
+
+ +
+
+
+ +
+ + +
+ + +
+
+ + + + +
+
+ +
+ + +

Profile Editor

+

${locked ? 'This is the locked MeshCentral Default profile. Clone it to create an editable theme.' : 'Edit values, save the profile, then export or apply after the global CSS hook is built.'}

+
+
+ + +
+
+ + +
+
+ ${CONTROL_GROUPS.map(function(group) { return renderControlGroup(group, variables, locked); }).join('')} +
+ + + +
+
+ +
+

Implementation Boundary

+

Theme Studio now saves editable profiles. Apply is intentionally disabled until the generated CSS hook, startup reapply behavior, and rollback marker are implemented.

+ + + + + + + +
ID${escapeHtml(profile.id || '')}
Storage${escapeHtml(state.storagePath || '')}
Source${escapeHtml(profile.source || '')}
Updated${escapeHtml(profile.updatedAt || profile.capturedAt || '')}
+
+ +
+

Raw Variable Inventory

+

Kept for audit and future mapping work. The editor above is the primary workflow.

+
${rawRows || '

No variables were found.

'}
+
+
+
+ +`; +} + +function renderProfileOptions(profiles, activeId) { + return profiles.map(function(profile) { + const label = `${profile.name || profile.id}${profile.locked ? ' (locked)' : ''}`; + return ``; + }).join(''); +} + +function renderControlGroup(group, variables, locked) { + const fields = group.fields.map(function(field) { + return renderField(field, variables[field.key], locked); + }).join(''); + + return `
+

${escapeHtml(group.label)}

+

${escapeHtml(group.description)}

+
${fields}
+
`; +} + +function renderField(field, value, locked) { + const name = formFieldName(field.key); + const id = `field-${name}`; + const current = value === undefined || value === null ? '' : String(value); + const disabled = locked ? 'disabled' : ''; + const readonly = locked ? 'readonly' : ''; + const fieldClass = field.type === 'color' ? 'field color' : 'field'; + let control = ''; + + if (field.type === 'select') { + control = ``; + } else if (field.type === 'textarea') { + control = ``; + } else if (field.type === 'color') { + const color = isHexColor(current) ? current : '#000000'; + control = ``; + } else { + control = ``; + } + + return `
${control}
`; +} + +function renderSelectOptions(options, current) { + const seen = new Set(); + const all = options.slice(); + if (current && all.indexOf(current) === -1) all.unshift(current); + return all.filter(function(option) { + if (seen.has(option)) return false; + seen.add(option); + return true; + }).map(function(option) { + return ``; + }).join(''); +} + +function renderRawInventory(groups) { + const order = ['brand', 'font', 'layout', 'color', 'button', 'table', 'icons', 'effects', 'advanced', 'other']; + return order.filter(function(group) { return groups[group] && groups[group].length; }).map(function(group) { + const body = groups[group].map(function(row) { + return `${escapeHtml(row.key)}${renderValue(row.value)}`; + }).join(''); + + return `

${escapeHtml(titleCase(group))}

${body}
VariableValue
`; + }).join(''); +} + +function getEditableFields() { + return CONTROL_GROUPS.reduce(function(fields, group) { + return fields.concat(group.fields); + }, []); +} + +function groupVariables(variables) { + const groups = {}; + Object.keys(variables).sort().forEach(function(key) { + const dot = key.indexOf('.'); + const group = dot > 0 ? key.substring(0, dot) : 'other'; + if (!groups[group]) groups[group] = []; + groups[group].push({ key, value: variables[key] }); + }); + return groups; +} + +function renderValue(value) { + const text = String(value); + const color = isColorValue(text) ? `` : ''; + return `${color}${escapeHtml(text)}`; +} + +function normalizeFieldValue(field, value) { + value = getPostedValue(value); + value = String(value || '').trim(); + if (field.type === 'color' && !isHexColor(value)) return '#000000'; + return value; +} + +function getPostedValue(value) { + if (Array.isArray(value)) return String(value[value.length - 1] || ''); + return String(value || ''); +} + +function formFieldName(key) { + return `var_${String(key).replace(/[^a-zA-Z0-9]/g, '_')}`; +} + +function uniqueProfileId(parent, base) { + base = base || 'theme'; + const existing = new Set(loadProfiles(parent).map(function(profile) { return profile.id; })); + if (!existing.has(base)) return base; + let index = 2; + while (existing.has(`${base}-${index}`)) index += 1; + return `${base}-${index}`; +} + +function cleanProfileName(value) { + const name = String(value || '').trim().replace(/\s+/g, ' '); + return name || 'Untitled Theme'; +} + +function slugify(value) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'theme'; +} + +function safeFileName(value) { + return String(value || 'theme').replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +function suggestCloneName(name) { + if (!name || name === 'MeshCentral Default') return 'Christ IT Theme'; + return `${name} Copy`; +} + +function getUsername(user) { + return (user && (user.name || user._id)) ? (user.name || user._id) : 'MeshCentral admin'; +} + +function isHexColor(value) { + return /^#([0-9a-f]{6})$/i.test(String(value || '')); +} + +function isColorValue(value) { + return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value) || /^rgba?\(/i.test(value); +} + +function titleCase(value) { + return String(value).replace(/[-_]/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }); +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, function(c) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; + }); +} + +function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, '`'); +} diff --git a/meshcentral/plugins/gracepress/CHANGELOG.md b/meshcentral/plugins/gracepress/CHANGELOG.md new file mode 100644 index 0000000..26aa12f --- /dev/null +++ b/meshcentral/plugins/gracepress/CHANGELOG.md @@ -0,0 +1,91 @@ +# Changelog + +## 0.0.18 - Theme Studio 0.0.3 catalog seed +- Updated the built-in GracePress Theme Studio catalog seed to version `0.0.3`. +- Changed the Theme Studio description to match the new profile editor role. +- Keeps the MeshCentral package manager authoritative over GracePress-managed plugin rows while preserving unrelated third-party plugin rows. + +## 0.0.17 - GracePress row ownership cleanup +- Resolves MeshCentral plugin registry row IDs from `_id`, `id`, or `pluginId` so cleanup works across MeshCentral database adapters. +- Archives and removes GracePress-owned legacy rows when their short name no longer matches the GracePress catalog entry. +- Archives and removes duplicate GracePress-owned rows for the same managed MeshCentral short name while preserving the active row when one exists. +- Reports missing row IDs as explicit sync errors instead of silently leaving obsolete GracePress rows in the MeshCentral plugin catalog. +- Keeps cleanup scoped to GracePress-managed plugin entries so unrelated third-party MeshCentral plugins are not touched. + +## 0.0.16 - Legacy row quarantine +- Archives and removes obsolete legacy MeshCentral plugin rows such as `gracepress-theme-studio` after preserving their registry document under the live plugin `.old` folder. +- Preserves the installed/active status of the corrected `gpthemestudio` row during catalog sync instead of forcing managed rows back to disabled. +- Keeps Theme Studio migration in the rolling catalog update path while avoiding duplicate disabled rows in MeshCentral's plugin list. + +## 0.0.15 - MeshCentral-safe Theme Studio seed +- Updated the built-in Theme Studio seed to use the MeshCentral-safe runtime short name `gpthemestudio`. +- Preserved the public GracePress catalog slug `gracepress-theme-studio` while separating it from MeshCentral's admin-route identity. +- Keeps the catalog-manager rolling update path as the migration mechanism for correcting installed MeshCentral plugin rows. +- Migrates the legacy `gracepress-theme-studio` MeshCentral row forward to `gpthemestudio` instead of creating a duplicate row. + +## 0.0.14 - Built-in MeshCentral plugin catalog seed +- Added a deterministic built-in GracePress MeshCentral plugin catalog seed for `GracePress Theme Studio`. +- Changed discovery to write disabled plugin rows through MeshCentral's database helper directly instead of the higher-level plugin wrapper. +- Added explicit sync-stage logging so MeshCentral service logs show whether discovery starts, reads existing rows, inserts rows, or fails. +- Kept the dynamic catalog URL in metadata for a later pass, but removed runtime catalog fetch as a dependency for exposing the first managed plugin. + +## 0.0.13 - Constructor startup sync +- Moved GracePress MeshCentral plugin discovery into the plugin constructor path because MeshCentral does not call `server_startup()` for already-installed plugins during ordinary service reload. +- Kept the `server_startup()` hook for install/upgrade compatibility while also scheduling discovery shortly after plugin load. +- Added an admin-panel fallback that starts discovery if the connector panel opens before the scheduled sync has run. + +## 0.0.12 - Catalog plugin discovery +- Added startup sync from the GracePress `mcplugins` catalog bucket into MeshCentral's plugin registry. +- Missing GracePress MeshCentral plugins are now inserted as disabled/available rows, ready for MeshCentral's normal Install action. +- Existing disabled rows are refreshed from their current `.mcplugin.json` config while active installed plugins are left alone. +- Added backend panel sync status so catalog discovery can be inspected from the connector admin view. + +## 0.0.11 - Facade confirmation update +- Published a marker-only update to confirm the MeshCentral git facade shape after the successful `0.0.10` upgrade. +- Preserved the MeshCentral-facing `downloadUrl` and `versionHistoryUrl` facade URLs in package config. +- Updated the backend panel test marker to `0.0.11`. + +## 0.0.10 - MeshCentral git facade test +- Added a marker-only update that uses GracePress-generated MeshCentral git facade URLs. +- Switched MeshCentral `downloadUrl` to the no-query `gracepress.download.php` package facade. +- Switched MeshCentral `versionHistoryUrl` to `gracepress.tags.json`, shaped like a GitHub tags response with `name` and `url` fields. +- Updated the backend panel test marker to `0.0.10`. + +## 0.0.9 - Versioned download URL retest +- Published a marker-only update that uses the versioned package URL as MeshCentral `downloadUrl`. +- Avoided the cached `gracepress-latest.zip` alias and the nginx path-info download route while validating the built-in Upgrade action. +- Updated the backend panel test marker to `0.0.9`. + +## 0.0.8 - Upgrade flow retest +- Published a small marker-only update for validating MeshCentral's built-in Upgrade action after the MySQL plugin registry repair. +- Updated the backend panel test marker to `0.0.8` so a successful install can be confirmed from the MeshCentral plugin panel. + +## 0.0.7 - Direct MeshCentral latest zip +- Replaced the query-string latest-download endpoint with a stable `gracepress-latest.zip` file because MeshCentral's plugin downloader builds requests from `URL.pathname` and drops query strings. +- Kept versioned GracePress release artifacts while adding a MeshCentral-safe latest zip alias for the built-in Upgrade action. +- Updated package metadata so installed MeshCentral config rows keep using the stable latest zip URL after upgrade. + +## 0.0.6 - Stable MeshCentral upgrade download +- Added a GracePress `mcplugin-download` endpoint for MeshCentral's built-in Upgrade action. +- Changed the MeshCentral-facing `downloadUrl` to a stable latest-download endpoint because MeshCentral upgrades from the installed DB row, not the freshly fetched latest config object. +- Kept the GracePress manifest `download_url` versioned for catalog and rollback clarity. + +## 0.0.5 - Upgrade detection test +- Published a small catalog-only test update for MeshCentral's built-in Upgrade action. +- Added a visible backend panel note so successful upgrade can be confirmed after MeshCentral installs the package. + +## 0.0.4 - MeshCentral lifecycle alignment +- Renamed the display/package name to GracePress Catalog Connector while preserving the stable `gracepress` slug and MeshCentral `shortName`. +- Changed MeshCentral repository metadata to `git` so MeshCentral's built-in installer and upgrade action can download the GracePress-hosted zip. +- Documented the active plugin identity rule: folder name, `shortName`, JS export, config row, and catalog slug must line up. +- Prepared the live registry cleanup from duplicate `gracepress` rows into one active catalog-managed plugin. + +## 0.0.3 - Catalog-managed backend panel +- Moved the live GracePress MeshCentral dummy plugin into the GracePress workspace as an `mcplugin` package. +- Added internal MeshCentral plugin header metadata for GracePress catalog builds. +- Added backend admin panel catalog checks against the GracePress manifest endpoint. +- Updated MeshCentral plugin config URLs to use the GracePress catalog-managed package, config, and changelog artifacts. + +## 0.0.2 - Backend hello-world route +- Replaced device-tab test behavior with a backend/admin plugin panel. +- Kept MeshCentral plugin permissions unused while the current MeshCentral database adapter reports missing plugin-permission helpers. diff --git a/meshcentral/plugins/gracepress/config.json b/meshcentral/plugins/gracepress/config.json new file mode 100644 index 0000000..b316827 --- /dev/null +++ b/meshcentral/plugins/gracepress/config.json @@ -0,0 +1,19 @@ +{ + "name": "GracePress Catalog Connector", + "shortName": "gracepress", + "version": "0.0.18", + "description": "MeshCentral backend connector for GracePress catalog-managed plugin delivery, updates, and diagnostics.", + "author": "Jason Eugene Garrison", + "homepage": "https://christit.com", + "repository": { + "type": "git", + "url": "https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress" + }, + "hasAdminPanel": true, + "main": "gracepress.js", + "configUrl": "https://christit.com/gracepress/gracepress.mcplugin.json", + "downloadUrl": "https://christit.com/gracepress/gracepress.download.php", + "changelogUrl": "https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress", + "versionHistoryUrl": "https://christit.com/gracepress/gracepress.tags.json", + "meshCentralCompat": ">=1.1.0" +} diff --git a/meshcentral/plugins/gracepress/gracepress.js b/meshcentral/plugins/gracepress/gracepress.js new file mode 100644 index 0000000..9d4d565 --- /dev/null +++ b/meshcentral/plugins/gracepress/gracepress.js @@ -0,0 +1,562 @@ +'use strict'; + +/** + * Plugin Name: GracePress Catalog Connector + * Description: MeshCentral backend connector for GracePress catalog-managed plugin delivery, updates, and diagnostics. + * Version: 0.0.18 + * Stage: Alpha + * Release Channel: Internal + * Public Release: No + * Public Candidate: No + * Category: MeshCentral + * Settings Slug: gracepress + * WordPress.org Slug: + * MeshCentral Short Name: gracepress + * MeshCentral Config URL: https://christit.com/gracepress/gracepress.mcplugin.json + * MeshCentral Compatibility: >=1.1.0 + * Requires PHP: 8.0 + * Icon: 🧩 + */ + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const PLUGIN = { + name: 'GracePress Catalog Connector', + shortName: 'gracepress', + version: '0.0.18', + catalogUrl: 'https://christit.com/gracepress/catalog.json', + manifestUrl: 'https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress', + configUrl: 'https://christit.com/gracepress/gracepress.mcplugin.json', + changelogUrl: 'https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress', +}; + +const STATIC_MCPLUGIN_CONFIGS = [ + { + name: 'GracePress Theme Studio', + shortName: 'gpthemestudio', + legacyShortNames: ['gracepress-theme-studio'], + version: '0.0.3', + description: 'MeshCentral UI theme profile editor for GracePress-managed MeshCentral servers.', + author: 'Jason Eugene Garrison', + homepage: 'https://christit.com', + repository: { + type: 'git', + url: 'https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress-theme-studio', + }, + hasAdminPanel: true, + main: 'gpthemestudio.js', + configUrl: 'https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json', + downloadUrl: 'https://christit.com/gracepress/gracepress-theme-studio.download.php', + changelogUrl: 'https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress-theme-studio', + versionHistoryUrl: 'https://christit.com/gracepress/gracepress-theme-studio.tags.json', + meshCentralCompat: '>=1.1.0', + }, +]; + +const syncState = { + lastStarted: '', + lastFinished: '', + running: false, + ok: false, + added: [], + updated: [], + skipped: [], + errors: [], +}; + +module.exports.gracepress = function(parent) { + const obj = { parent }; + obj.exports = []; + + console.log(`${PLUGIN.name} loaded.`); + console.log(`${PLUGIN.name} scheduling built-in MeshCentral plugin catalog sync.`); + + syncMeshCentralCatalog(parent); + scheduleCatalogSync(parent, 5000); + + obj.server_startup = function() { + syncMeshCentralCatalog(parent); + }; + + obj.handleAdminReq = function(req, res, user) { + if (!syncState.lastStarted && !syncState.running) { + scheduleCatalogSync(parent, 0); + } + checkCatalog(function(catalog) { + const username = (user && (user.name || user._id)) ? (user.name || user._id) : 'MeshCentral admin'; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderAdmin(username, catalog)); + }); + }; + + return obj; +}; + +function scheduleCatalogSync(parent, delayMs) { + setTimeout(function() { + syncMeshCentralCatalog(parent); + }, delayMs); +} + +function checkCatalog(done) { + const url = `${PLUGIN.manifestUrl}&nocache=${Date.now()}`; + https.get(url, { timeout: 4500 }, function(response) { + let body = ''; + response.setEncoding('utf8'); + response.on('data', function(chunk) { + body += chunk; + if (body.length > 262144) { + response.destroy(new Error('Catalog response too large.')); + } + }); + response.on('end', function() { + try { + const data = JSON.parse(body); + done({ + ok: response.statusCode >= 200 && response.statusCode < 300 && data && !data.error, + statusCode: response.statusCode, + manifest: data, + checkedAt: new Date().toISOString(), + error: data && data.error ? data.error : '', + }); + } catch (err) { + done({ ok: false, statusCode: response.statusCode, checkedAt: new Date().toISOString(), error: err.message }); + } + }); + }).on('timeout', function() { + this.destroy(new Error('Catalog request timed out.')); + }).on('error', function(err) { + done({ ok: false, statusCode: 0, checkedAt: new Date().toISOString(), error: err.message }); + }); +} + +function syncMeshCentralCatalog(parent) { + if (syncState.running) return; + + syncState.running = true; + syncState.ok = false; + syncState.lastStarted = new Date().toISOString(); + syncState.lastFinished = ''; + syncState.added = []; + syncState.updated = []; + syncState.skipped = []; + syncState.errors = []; + + runCatalogSync(parent).then(function(summary) { + syncState.ok = true; + syncState.added = summary.added; + syncState.updated = summary.updated; + syncState.skipped = summary.skipped; + syncState.errors = summary.errors; + console.log(`${PLUGIN.name} synced MeshCentral catalog: added ${summary.added.length}, updated ${summary.updated.length}, skipped ${summary.skipped.length}, errors ${summary.errors.length}.`); + }).catch(function(err) { + syncState.errors.push(err && err.message ? err.message : String(err)); + console.log(`${PLUGIN.name} catalog sync failed: ${syncState.errors.join('; ')}`); + }).finally(function() { + syncState.running = false; + syncState.lastFinished = new Date().toISOString(); + }); +} + +async function runCatalogSync(parent) { + const summary = { added: [], updated: [], skipped: [], errors: [] }; + const entries = STATIC_MCPLUGIN_CONFIGS.slice(); + console.log(`${PLUGIN.name} sync starting with ${entries.length} built-in MeshCentral plugin entries.`); + const existing = await getExistingPlugins(parent); + console.log(`${PLUGIN.name} sync found ${existing.length} MeshCentral plugin registry rows.`); + const byShortName = new Map(); + const rowsByShortName = new Map(); + + existing.forEach(function(row) { + const shortName = row && row.shortName ? row.shortName : ''; + if (!shortName) return; + if (!byShortName.has(shortName)) byShortName.set(shortName, row); + if (!rowsByShortName.has(shortName)) rowsByShortName.set(shortName, []); + rowsByShortName.get(shortName).push(row); + }); + + for (const entry of entries) { + const shortName = (entry && entry.shortName) || ''; + const configUrl = entry && entry.configUrl; + + if (!shortName || !configUrl) { + summary.skipped.push(`${entry && entry.name ? entry.name : 'unknown'}: missing MeshCentral config URL`); + continue; + } + + if (shortName === PLUGIN.shortName) { + summary.skipped.push(`${shortName}: active connector package`); + continue; + } + + const pluginRow = normalizePluginConfig(entry); + if (!pluginRow || !pluginRow.shortName || !pluginRow.downloadUrl) { + summary.errors.push(`${shortName}: invalid MeshCentral plugin config`); + continue; + } + + const legacyRows = []; + if (Array.isArray(entry.legacyShortNames)) { + for (const legacyShortName of entry.legacyShortNames) { + if (rowsByShortName.has(legacyShortName)) legacyRows.push.apply(legacyRows, rowsByShortName.get(legacyShortName)); + } + } + + for (const legacyRow of legacyRows) { + if (!isGracePressManagedRow(legacyRow)) continue; + const legacyId = getPluginRowId(legacyRow); + if (legacyId !== undefined && legacyId !== null) { + await archivePluginRow(parent, legacyRow, 'legacy-shortname'); + await deletePluginRow(parent, legacyId, legacyRow.shortName); + byShortName.delete(legacyRow.shortName); + summary.updated.push(`${legacyRow.name || legacyRow.shortName} legacy archived`); + } else if (legacyRow && legacyRow.shortName) { + summary.errors.push(`${legacyRow.shortName}: legacy row id missing`); + } + } + + const currentRows = rowsByShortName.get(pluginRow.shortName) || []; + const current = chooseCurrentPluginRow(currentRows); + for (const duplicateRow of currentRows) { + if (duplicateRow === current || !isGracePressManagedRow(duplicateRow)) continue; + const duplicateId = getPluginRowId(duplicateRow); + if (duplicateId !== undefined && duplicateId !== null) { + await archivePluginRow(parent, duplicateRow, 'duplicate-shortname'); + await deletePluginRow(parent, duplicateId, duplicateRow.shortName); + summary.updated.push(`${duplicateRow.name || duplicateRow.shortName} duplicate archived`); + } else { + summary.errors.push(`${duplicateRow.shortName}: duplicate row id missing`); + } + } + + if (!current) { + console.log(`${PLUGIN.name} adding disabled MeshCentral plugin row for ${pluginRow.shortName}.`); + await addPluginRow(parent, pluginRow); + byShortName.set(pluginRow.shortName, pluginRow); + summary.added.push(`${pluginRow.name} ${pluginRow.version}`); + continue; + } + + const refreshed = Object.assign({}, pluginRow, { status: Number(current.status) || 0 }); + const currentId = getPluginRowId(current); + if (currentId !== undefined && currentId !== null) refreshed._id = currentId; + + if (pluginRowsMatch(current, refreshed)) { + summary.skipped.push(`${pluginRow.shortName}: already available`); + continue; + } + + await updatePluginRow(parent, currentId, refreshed); + byShortName.set(pluginRow.shortName, refreshed); + summary.updated.push(`${pluginRow.name} ${pluginRow.version}`); + } + + return summary; +} + +function getJson(url) { + return new Promise(function(resolve, reject) { + https.get(url, { timeout: 6000 }, function(response) { + let body = ''; + response.setEncoding('utf8'); + response.on('data', function(chunk) { + body += chunk; + if (body.length > 1048576) { + response.destroy(new Error('Response too large.')); + } + }); + response.on('end', function() { + if (response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`HTTP ${response.statusCode}`)); + return; + } + try { + resolve(JSON.parse(body)); + } catch (err) { + reject(err); + } + }); + }).on('timeout', function() { + this.destroy(new Error('Request timed out.')); + }).on('error', reject); + }); +} + +function getExistingPlugins(parent) { + const db = parent && parent.parent && parent.parent.db; + return new Promise(function(resolve, reject) { + if (!db || typeof db.getPlugins !== 'function') { + reject(new Error('MeshCentral plugin database helper is unavailable.')); + return; + } + db.getPlugins(function(err, docs) { + if (err) { + reject(err); + return; + } + resolve((Array.isArray(docs) ? docs : []).map(parsePluginDoc).filter(Boolean)); + }); + }); +} + +function addPluginRow(parent, pluginRow) { + const db = parent && parent.parent && parent.parent.db; + return new Promise(function(resolve, reject) { + if (!db || typeof db.addPlugin !== 'function') { + reject(new Error('MeshCentral addPlugin helper is unavailable.')); + return; + } + db.addPlugin(pluginRow, function(err) { + if (err) reject(err); + else resolve(); + }); + }); +} + +function updatePluginRow(parent, id, pluginRow) { + const db = parent && parent.parent && parent.parent.db; + return new Promise(function(resolve, reject) { + if (id === undefined || id === null || !db || typeof db.updatePlugin !== 'function') { + reject(new Error(`Cannot update ${pluginRow.shortName}; plugin id or update helper is unavailable.`)); + return; + } + db.updatePlugin(id, pluginRow, function(err) { + if (err) reject(err); + else resolve(); + }); + }); +} + +function deletePluginRow(parent, id, shortName) { + const db = parent && parent.parent && parent.parent.db; + return new Promise(function(resolve, reject) { + if (id === undefined || id === null || !db || typeof db.deletePlugin !== 'function') { + reject(new Error(`Cannot delete legacy ${shortName}; plugin id or delete helper is unavailable.`)); + return; + } + db.deletePlugin(id, function(err) { + if (err) reject(err); + else resolve(); + }); + }); +} + +function archivePluginRow(parent, pluginRow, reason) { + return new Promise(function(resolve, reject) { + try { + const pluginPath = parent && parent.pluginPath ? parent.pluginPath : ''; + if (!pluginPath) { + resolve(); + return; + } + + const oldDir = path.join(pluginPath, '.old'); + fs.mkdirSync(oldDir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); + const shortName = String(pluginRow.shortName || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_'); + const id = String(getPluginRowId(pluginRow) || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_'); + const file = path.join(oldDir, `gracepress-catalog-${reason}-${shortName}-${id}-${stamp}.json`); + fs.writeFileSync(file, JSON.stringify(pluginRow, null, 2) + '\n', 'utf8'); + resolve(); + } catch (err) { + reject(err); + } + }); +} + +function parsePluginDoc(doc) { + if (!doc) return null; + const outerId = getPluginRowId(doc); + const attachOuterId = function(parsed) { + if (!parsed || typeof parsed !== 'object') return parsed; + if (outerId !== undefined && outerId !== null && parsed._id === undefined && parsed.id === undefined) parsed._id = outerId; + return parsed; + }; + + if (typeof doc === 'string') { + try { return JSON.parse(doc); } catch (err) { return null; } + } + if (typeof doc.doc === 'string') { + try { return attachOuterId(JSON.parse(doc.doc)); } catch (err) { return null; } + } + if (doc.doc && typeof doc.doc === 'object') return attachOuterId(doc.doc); + return doc; +} + +function getPluginRowId(row) { + if (!row || typeof row !== 'object') return undefined; + if (row._id !== undefined && row._id !== null) return row._id; + if (row.id !== undefined && row.id !== null) return row.id; + if (row.pluginId !== undefined && row.pluginId !== null) return row.pluginId; + return undefined; +} + +function chooseCurrentPluginRow(rows) { + if (!Array.isArray(rows) || !rows.length) return null; + return rows.find(function(row) { return Number(row && row.status) === 1; }) || rows[0]; +} + +function isGracePressManagedRow(row) { + if (!row || typeof row !== 'object') return false; + const values = [ + row.name, + row.shortName, + row.configUrl, + row.downloadUrl, + row.changelogUrl, + row.versionHistoryUrl, + row.homepage, + row.repository && row.repository.url, + ].map(function(value) { + return String(value || '').toLowerCase(); + }); + + return values.some(function(value) { + return value.indexOf('gracepress') !== -1 || value.indexOf('christit.com/gracepress') !== -1; + }); +} + +function normalizePluginConfig(config) { + if (!config || !config.shortName || !config.name) return null; + + return { + name: config.name, + shortName: config.shortName, + version: config.version || '0.0.0', + description: config.description || '', + hasAdminPanel: !!config.hasAdminPanel, + homepage: config.homepage || 'https://christit.com', + repository: { + type: (config.repository && config.repository.type) || 'git', + url: (config.repository && config.repository.url) || config.configUrl || '', + }, + configUrl: config.configUrl || '', + downloadUrl: config.downloadUrl || '', + changelogUrl: config.changelogUrl || '', + versionHistoryUrl: config.versionHistoryUrl || '', + meshCentralCompat: config.meshCentralCompat || '>=1.1.0', + status: 0, + }; +} + +function pluginRowsMatch(left, right) { + const fields = [ + 'name', + 'shortName', + 'version', + 'description', + 'hasAdminPanel', + 'homepage', + 'configUrl', + 'downloadUrl', + 'changelogUrl', + 'versionHistoryUrl', + 'meshCentralCompat', + 'status', + ]; + + for (const field of fields) { + if (String(left[field] === undefined ? '' : left[field]) !== String(right[field] === undefined ? '' : right[field])) return false; + } + + const leftRepo = left.repository || {}; + const rightRepo = right.repository || {}; + return String(leftRepo.type || '') === String(rightRepo.type || '') && String(leftRepo.url || '') === String(rightRepo.url || ''); +} + +function renderAdmin(username, catalog) { + const manifest = catalog && catalog.manifest ? catalog.manifest : {}; + const catalogVersion = manifest.version || ''; + const updateAvailable = catalogVersion && compareVersions(catalogVersion, PLUGIN.version) > 0; + const statusText = catalog && catalog.ok ? 'Catalog reachable' : 'Catalog unavailable'; + const statusClass = catalog && catalog.ok ? 'ok' : 'warn'; + const updateText = updateAvailable ? `Update available: v${escapeHtml(catalogVersion)}` : 'Installed version is current for this catalog response.'; + + return ` + + + + ${escapeHtml(PLUGIN.name)} + + + +
+
+

${escapeHtml(PLUGIN.name)}

+

This backend panel is served by the GracePress catalog-managed MeshCentral plugin package. Test update channel: 0.0.18.

+
+
User
${escapeHtml(username)}
+
Short name
${escapeHtml(PLUGIN.shortName)}
+
Installed version
${escapeHtml(PLUGIN.version)}
+
Catalog status
${escapeHtml(statusText)}
+
Last check
${escapeHtml((catalog && catalog.checkedAt) || '')}
+
Update status
${escapeHtml(updateText)}
+
Plugin sync
${escapeHtml(renderSyncSummaryText())}
+
+
+
+

GracePress Catalog Links

+

Manifest: ${escapeHtml(PLUGIN.manifestUrl)}

+

MeshCentral config: ${escapeHtml(PLUGIN.configUrl)}

+

Changelog: ${escapeHtml(PLUGIN.changelogUrl)}

+ ${catalog && catalog.error ? `

Catalog error: ${escapeHtml(catalog.error)}

` : ''} + ${renderSyncDetails()} +
+
+ +`; +} + +function renderSyncSummaryText() { + if (syncState.running) return `Running since ${syncState.lastStarted}`; + if (!syncState.lastFinished) return 'Waiting for MeshCentral startup sync.'; + if (syncState.ok) { + return `Last sync ${syncState.lastFinished}: added ${syncState.added.length}, updated ${syncState.updated.length}, skipped ${syncState.skipped.length}, errors ${syncState.errors.length}.`; + } + return `Last sync failed ${syncState.lastFinished}: ${syncState.errors.join('; ')}`; +} + +function renderSyncDetails() { + const lines = []; + if (syncState.added.length) lines.push(`

Added as available: ${escapeHtml(syncState.added.join(', '))}

`); + if (syncState.updated.length) lines.push(`

Updated available rows: ${escapeHtml(syncState.updated.join(', '))}

`); + if (syncState.skipped.length) lines.push(`

Skipped: ${escapeHtml(syncState.skipped.join(', '))}

`); + if (syncState.errors.length) lines.push(`

Sync errors: ${escapeHtml(syncState.errors.join('; '))}

`); + return lines.join('\n'); +} + +function compareVersions(a, b) { + const left = String(a || '0').split('.').map(function(part) { return parseInt(part, 10) || 0; }); + const right = String(b || '0').split('.').map(function(part) { return parseInt(part, 10) || 0; }); + const length = Math.max(left.length, right.length); + for (let i = 0; i < length; i++) { + const diff = (left[i] || 0) - (right[i] || 0); + if (diff !== 0) return diff; + } + return 0; +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, function(c) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; + }); +} + +function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, '`'); +} diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 0000000..622807d --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,5 @@ +# Vendor And Reference Code + +This folder is for notes or untracked local clones used while developing MeshCentral integrations. + +The full upstream MeshCentral source tree is intentionally not imported into this repository on the initial consolidation pass. If we need to maintain a real MeshCentral fork later, create that as a deliberate separate decision with a documented upstream remote and patch policy. diff --git a/wordpress/plugins/mesh-press/CHANGELOG.md b/wordpress/plugins/mesh-press/CHANGELOG.md new file mode 100644 index 0000000..d12f6dc --- /dev/null +++ b/wordpress/plugins/mesh-press/CHANGELOG.md @@ -0,0 +1,163 @@ +# GracePress: MeshPress Sync Changelog + +## Current +- Added a new-server onboarding wizard for bridge URL, token, public MeshCentral URL, advanced mode, and first health checks. +- Added a connection health panel for WordPress-to-bridge, bridge-to-MeshCentral, public URL, fleet freshness, bridge push, and OmniLog event flow. +- Added an advanced-mode gate for MeshCentral Ops and raw troubleshooting controls. +- Added an agent enrollment check to confirm newly installed agents appear in the selected MeshCentral group. +- Added a protected bridge event receiver so MeshPress Bridge lifecycle events can flow into OmniLog. + +## 1.13.0 - 2026-06-12 +- Added a new-server onboarding wizard for bridge URL, token, public MeshCentral URL, advanced mode, and first health checks. +- Added a connection health panel for WordPress-to-bridge, bridge-to-MeshCentral, public URL, fleet freshness, bridge push, and OmniLog event flow. +- Added an advanced-mode gate for MeshCentral Ops and raw troubleshooting controls. +- Added an agent enrollment check to confirm newly installed agents appear in the selected MeshCentral group. +- Added a protected bridge event receiver so MeshPress Bridge lifecycle events can flow into OmniLog. + +## 1.12.7 - 2026-05-25 +- Added separate controls for bridge maintenance and real MeshCentral maintenance mode. +- Added redacted MeshCentral config audit output for safer copy/paste diagnostics. +- Upgraded backups to request full MeshCentral snapshots, including MySQL/MariaDB dumps when configured. + +## 1.12.6 - 2026-05-25 +- Rolled MeshPress admin back to the last known stable 1.12.1 layout after later rescue edits broke tabs, buttons, and fleet loading. +- Preserved MeshCentral `@` characters in group/device identifiers before sending requests to the bridge. +- Removed browser dark-mode override CSS that inverted MeshPress cards in light mode. +- Restored explicit MeshCentral maintenance-mode enable/disable controls now that the bridge endpoint is stable again. +- Simplified agent provisioning into one primary Linux install command plus a compact installer/link picker. +- Added clearer Mesh ID diagnostics without forcing operators to manually paste or understand the raw MeshCentral group id. + +## 1.12.5 - 2026-05-24 +- Rolled MeshPress admin back to the last known stable 1.12.1 layout after later rescue edits broke tabs, buttons, and fleet loading. +- Preserved MeshCentral `@` characters in group/device identifiers before sending requests to the bridge. +- Removed browser dark-mode override CSS that inverted MeshPress cards in light mode. + +## 1.12.0 - 2026-05-24 +- Added a MeshCentral Ops tab for runtime/update checks, guarded backups, and agent uninstall diagnostics through the bridge. +- Added an operator-only uninstall-agent test button to the device troubleshooting flow. +- Added admin output panels designed for copyable diagnostics when bridge maintenance or MeshCentral commands fail. + +## 1.11.9 - 2026-05-24 +- Improved the fleet overview with grouped device cards, search, group filtering, status filtering, and direct Desktop/Terminal/Files actions. +- Expanded agent deployment output for Windows, macOS, Linux, Mesh Assistant, and copyable Linux install commands. +- Added clearer provisioning guidance and bridge diagnostic output for group edit failures. + +## 1.11.8 - 2026-05-24 +- Routed fleet Desktop/Console/Files buttons through bridge-generated session links instead of locally guessed URLs. +- Preserved MeshCentral `$` node ids through AJAX sanitization and made `viewmode-first + gotonode` the tested primary path. +- Added session diagnostics plus manual group edit, device rename, and device move controls for bridge testing. + +## 1.11.7 - 2026-05-23 +- Added bridge runtime version/status visibility and manual update triggering from MeshPress admin. +- Added a richer Desktop/Console/Files session link tester with multiple MeshCentral direct-link candidates. +- Cleaned mojibake labels from fleet action buttons. + +## 1.11.6 - 2026-05-23 +- Added Bridge Runtime Status controls for version checks and manual bridge updates. +- Added support for bridge `/api/session-links` and console link candidate testing. +- Renamed Terminal actions to Console in the UI to match the workflow being tested. + +## 1.11.5 - 2026-05-23 +- Added a Diagnostics & Manual Actions tab for bridge probing and first-party MeshCentral validation. +- Added manual MeshCentral group creation from MeshPress, with clear output and generated agent links. +- Added read-only bridge probes for health, status, self-test, groups, devices, users, events, server info, and device info. +- Added direct session link testing for Desktop, Terminal, and Files in both `gotonode` and `node` URL formats. + + + +## Reconstructed History + +These entries were inferred from local distribution archives, module headers, and `.old` backups. They are intentionally conservative and may not capture the full intent of each change. + +### 1.11.4 - reconstructed +- Reconstructed from archive `mesh-press-v1.11.4.zip` (2026-05-21). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Header advertised WordPress compatibility through 7.0. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.11.3 - reconstructed +- Reconstructed from archive `mesh-press-v1.11.3.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.11.2 - reconstructed +- Reconstructed from archive `mesh-press-v1.11.2.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.11.1 - reconstructed +- Reconstructed from archive `mesh-press-v1.11.1.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.11.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.11.0.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.10.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.10.0.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.9.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.9.0.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.8.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.8.0.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.7.1 - reconstructed +- Reconstructed from archive `mesh-press-v1.7.1.zip` (2026-05-20). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects to the custom MeshCentral GracePress API plugin for flawless fleet syncing and management. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.7.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.7.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Connects to the custom MeshCentral GracePress API plugin for flawless fleet syncing and management. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.5.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.5.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Uses a secure Cookie-Based REST API bridge to sync your MeshCentral fleet into WordPress. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.4.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.4.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Uses a native WebSocket payload engine to sync your MeshCentral fleet into WordPress. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.3.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.3.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Uses a native WebSocket engine to sync your MeshCentral fleet into WordPress. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.1.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.1.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Syncs your MeshCentral fleet into WordPress for instant remote desktops and API-driven automation. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + +### 1.0.0 - reconstructed +- Reconstructed from archive `mesh-press-v1.0.0.zip` (2026-05-18). +- Header description at this version: Enterprise Remote Monitoring & Management (RMM) bridge. Syncs your MeshCentral fleet into WordPress for instant remote desktops and API-driven automation. +- Catalog/category marker: Utility. +- Header icon marker: 🌐. + + diff --git a/wordpress/plugins/mesh-press/mesh-press.php b/wordpress/plugins/mesh-press/mesh-press.php new file mode 100644 index 0000000..7dd31dd --- /dev/null +++ b/wordpress/plugins/mesh-press/mesh-press.php @@ -0,0 +1,1843 @@ + false, + 'body' => wp_json_encode(['plugin' => 'mesh-press', 'action' => $a]), + 'headers' => ['Content-Type' => 'application/json'] + ]); + } +} +register_activation_hook(__FILE__, function() { gp_mesh_ping('activate'); }); +register_deactivation_hook(__FILE__, function() { gp_mesh_ping('deactivate'); }); + +if (!class_exists('GracePress_MeshPress')) { + class GracePress_MeshPress { + + public function __construct() { + add_action('admin_menu', [$this, 'register_admin_page'], 120); + + // AJAX Handlers + add_action('wp_ajax_gp_mesh_save_auth', [$this, 'ajax_save_auth']); + add_action('wp_ajax_gp_mesh_get_fleet', [$this, 'ajax_get_fleet']); + add_action('wp_ajax_gp_mesh_dispatch', [$this, 'ajax_dispatch_action']); + add_action('wp_ajax_gp_mesh_bridge_probe', [$this, 'ajax_bridge_probe']); + add_action('wp_ajax_gp_mesh_update_bridge', [$this, 'ajax_update_bridge']); + add_action('wp_ajax_gp_mesh_create_group', [$this, 'ajax_create_group']); + add_action('wp_ajax_gp_mesh_agent_links', [$this, 'ajax_agent_links']); + add_action('wp_ajax_gp_mesh_device_links', [$this, 'ajax_device_links']); + add_action('wp_ajax_gp_mesh_device_info', [$this, 'ajax_device_info']); + add_action('wp_ajax_gp_mesh_session_diagnostics', [$this, 'ajax_session_diagnostics']); + add_action('wp_ajax_gp_mesh_resolve_group', [$this, 'ajax_resolve_group']); + add_action('wp_ajax_gp_mesh_edit_group', [$this, 'ajax_edit_group']); + add_action('wp_ajax_gp_mesh_rename_device', [$this, 'ajax_rename_device']); + add_action('wp_ajax_gp_mesh_move_device', [$this, 'ajax_move_device']); + add_action('wp_ajax_gp_mesh_uninstall_agent', [$this, 'ajax_uninstall_agent']); + add_action('wp_ajax_gp_mesh_maintenance_probe', [$this, 'ajax_maintenance_probe']); + add_action('wp_ajax_gp_mesh_toggle_maintenance', [$this, 'ajax_toggle_maintenance']); + add_action('wp_ajax_gp_mesh_toggle_meshcentral_maintenance', [$this, 'ajax_toggle_meshcentral_maintenance']); + add_action('wp_ajax_gp_meshcentral_config_audit', [$this, 'ajax_meshcentral_config_audit']); + add_action('wp_ajax_gp_meshcentral_backup', [$this, 'ajax_meshcentral_backup']); + add_action('wp_ajax_gp_meshcentral_update', [$this, 'ajax_meshcentral_update']); + add_action('wp_ajax_gp_mesh_clear_cache', [$this, 'ajax_clear_cache']); + add_action('wp_ajax_gp_mesh_push_now', [$this, 'ajax_push_now']); + add_action('wp_ajax_gp_mesh_health_check', [$this, 'ajax_health_check']); + add_action('wp_ajax_gp_mesh_wait_enrollment', [$this, 'ajax_wait_enrollment']); + add_action('rest_api_init', [$this, 'register_rest_routes']); + + // API Exporter for Service Desk (Decoupled Integration) + add_filter('gracepress_get_mesh_fleet', [$this, 'export_fleet_to_service_desk'], 10, 1); + } + + // ===================================================================== + // 2. DECOUPLED SERVICE DESK HOOK (Reads from Cache) + // ===================================================================== + public function export_fleet_to_service_desk($empty_array) { + $fleet = get_transient('gp_mesh_fleet_cache'); + if ($fleet && is_array($fleet)) return $fleet; + $nodes = get_transient('gp_mesh_nodes_cache'); + if (!$nodes || !is_array($nodes)) return ['groups' => [], 'devices' => []]; + return ['groups' => [], 'devices' => $nodes]; + } + + // ===================================================================== + // 3. AJAX HANDLERS + // ===================================================================== + public function ajax_save_auth() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(); + + update_option('gp_mesh_host', rtrim(esc_url_raw($_POST['host']), '/')); + update_option('gp_mesh_key', sanitize_text_field($_POST['api_key'])); + update_option('gp_mesh_public_url', rtrim(esc_url_raw($_POST['public_url'] ?? ''), '/')); + update_option('gp_mesh_mode', sanitize_key($_POST['mode'] ?? 'bridge')); + update_option('gp_mesh_link_mode', sanitize_key($_POST['link_mode'] ?? 'gotonode')); + update_option('gp_mesh_advanced_mode', !empty($_POST['advanced_mode']) && $_POST['advanced_mode'] !== '0' ? 'yes' : 'no'); + if (function_exists('gp_log')) gp_log("MeshPress bridge configuration saved.", 'GP-MESH'); + wp_send_json_success(); + } + + private function bridge_request($path, $args = []) { + $host = rtrim((string)get_option('gp_mesh_host', ''), '/'); + $key = (string)get_option('gp_mesh_key', ''); + if ($host === '') return new WP_Error('gp_mesh_missing_host', 'Bridge URL is missing.'); + $headers = []; + if ($key !== '') $headers['Authorization'] = 'Bearer ' . $key; + return wp_remote_get($host . $path, array_merge(['timeout' => 20, 'headers' => $headers], $args)); + } + + private function bridge_post($path, $body = [], $args = []) { + $host = rtrim((string)get_option('gp_mesh_host', ''), '/'); + $key = (string)get_option('gp_mesh_key', ''); + if ($host === '') return new WP_Error('gp_mesh_missing_host', 'Bridge URL is missing.'); + $headers = ['Content-Type' => 'application/json']; + if ($key !== '') $headers['Authorization'] = 'Bearer ' . $key; + return wp_remote_post($host . $path, array_merge([ + 'timeout' => 25, + 'headers' => $headers, + 'body' => wp_json_encode($body), + ], $args)); + } + + private function bridge_json_response($response) { + if (is_wp_error($response)) { + return ['success' => false, 'error' => $response->get_error_message(), 'code' => 0]; + } + $code = (int)wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + $data = json_decode($body, true); + if (!is_array($data)) { + return ['success' => false, 'error' => 'Bridge returned invalid JSON.', 'code' => $code, 'raw' => substr($body, 0, 500)]; + } + $data['code'] = $code; + if ($code >= 400 && !isset($data['success'])) $data['success'] = false; + return $data; + } + + private function cache_clear() { + delete_transient('gp_mesh_fleet_cache'); + delete_transient('gp_mesh_nodes_cache'); + } + + private function device_link($node_id, $mode) { + $public = rtrim((string)get_option('gp_mesh_public_url', 'https://mesh.christit.com'), '/'); + if (!$public) $public = rtrim((string)get_option('gp_mesh_host', ''), '/'); + if ($mode === 'console') $mode = 'terminal'; + $view_modes = ['desktop' => '11', 'terminal' => '12', 'files' => '13']; + $link_mode = get_option('gp_mesh_link_mode', 'gotonode'); + $node_param = $link_mode === 'node' ? 'node' : 'gotonode'; + $link_node = $node_param === 'gotonode' ? basename(str_replace('\\', '/', (string)$node_id)) : (string)$node_id; + $encoded = rawurlencode($link_node); + if ($node_param === 'gotonode') $encoded = str_replace('%24', '$', $encoded); + return $public . '/?viewmode=' . rawurlencode($view_modes[$mode] ?? '11') . '&' . rawurlencode($node_param) . '=' . $encoded; + } + + private function sanitize_mesh_id($value) { + return preg_replace('/[^A-Za-z0-9_\-\/\$:@.]/', '', (string)$value); + } + + public function register_rest_routes() { + register_rest_route('gracepress/v1', '/meshpress/fleet', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_receive_fleet'], + 'permission_callback' => '__return_true' + ]); + register_rest_route('gracepress/v1', '/meshpress/event', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_receive_event'], + 'permission_callback' => '__return_true' + ]); + } + + private function request_key($request) { + $header = (string)$request->get_header('authorization'); + if (stripos($header, 'Bearer ') === 0) return trim(substr($header, 7)); + return (string)$request->get_param('key'); + } + + public function rest_receive_fleet($request) { + $expected = (string)get_option('gp_mesh_key', ''); + if ($expected === '' || !hash_equals($expected, $this->request_key($request))) { + return new WP_REST_Response(['success' => false, 'error' => 'Unauthorized'], 401); + } + $params = $request->get_json_params(); + if (!is_array($params)) $params = []; + $groups = is_array($params['groups'] ?? null) ? $params['groups'] : []; + $devices = is_array($params['devices'] ?? null) ? $params['devices'] : []; + $fleet = [ + 'groups' => $groups, + 'devices' => $devices, + 'synced_at' => current_time('mysql'), + 'source' => 'push', + 'bridge_version' => sanitize_text_field($params['bridge_version'] ?? '') + ]; + set_transient('gp_mesh_fleet_cache', $fleet, DAY_IN_SECONDS); + set_transient('gp_mesh_nodes_cache', $devices, DAY_IN_SECONDS); + if (function_exists('gp_log')) gp_log("MeshPress Bridge pushed fleet snapshot.", 'GP-MESH', ['severity' => 'SUCCESS', 'devices' => count($devices), 'groups' => count($groups)]); + return ['success' => true, 'devices' => count($devices), 'groups' => count($groups)]; + } + + public function rest_receive_event($request) { + $expected = (string)get_option('gp_mesh_key', ''); + if ($expected === '' || !hash_equals($expected, $this->request_key($request))) { + return new WP_REST_Response(['success' => false, 'error' => 'Unauthorized'], 401); + } + $params = $request->get_json_params(); + if (!is_array($params)) $params = []; + $event = sanitize_key($params['event'] ?? 'bridge_event'); + $severity = sanitize_key($params['severity'] ?? 'INFO'); + $data = is_array($params['data'] ?? null) ? $params['data'] : []; + set_transient('gp_mesh_last_bridge_event', [ + 'event' => $event, + 'severity' => strtoupper($severity), + 'bridge_version' => sanitize_text_field($params['bridge_version'] ?? ''), + 'data' => $data, + 'received_at' => current_time('mysql') + ], DAY_IN_SECONDS); + if (function_exists('gp_log')) { + gp_log('MeshPress Bridge event: ' . $event, 'GP-MESH', [ + 'severity' => strtoupper($severity), + 'event' => $event, + 'bridge_version' => sanitize_text_field($params['bridge_version'] ?? ''), + 'data' => $data + ]); + } + return ['success' => true, 'event' => $event]; + } + + private function render_fleet_table($nodes, $meshes) { + $mesh_map = []; + foreach((array)$meshes as $m) $mesh_map[$m['_id'] ?? $m['id'] ?? ''] = $m['name'] ?? 'Unknown'; + $grouped = []; + foreach((array)$nodes as $n) { + if(!isset($n['name'])) continue; + $group_id = $n['groupId'] ?? $n['meshid'] ?? ''; + $groupName = $n['groupName'] ?? ($mesh_map[$group_id] ?? 'Ungrouped'); + if (!isset($grouped[$groupName])) $grouped[$groupName] = []; + $grouped[$groupName][] = $n; + } + ksort($grouped, SORT_NATURAL | SORT_FLAG_CASE); + + $html = '
'; + $html .= '
' . count((array)$nodes) . ' devices across ' . count($grouped) . ' visible groups.
'; + $html .= ''; + $html .= ''; + $html .= '
'; + + foreach($grouped as $groupName => $devices) { + $online = 0; + foreach($devices as $n) $online += ((isset($n['online']) ? (bool)$n['online'] : (isset($n['conn']) && $n['conn'] > 0)) ? 1 : 0); + $html .= '
'; + $html .= '
' . esc_html($groupName) . '' . count($devices) . ' devices, ' . $online . ' online
'; + $html .= '
'; + foreach($devices as $n) { + $node_id = $n['id'] ?? $n['_id'] ?? ''; + $isOnline = isset($n['online']) ? (bool)$n['online'] : (isset($n['conn']) && $n['conn'] > 0); + $statusTxt = $isOnline ? 'Online' : 'Offline'; + $ip = $n['ip'] ?? $n['paddr'] ?? '---'; + $search = strtolower(($n['name'] ?? '') . ' ' . $groupName . ' ' . $ip . ' ' . $statusTxt); + $safe_node = esc_attr($node_id); + $html .= '
'; + $html .= '
' . esc_html($n['name']) . '' . esc_html($ip) . '
'; + $html .= '
'; + if($node_id) { + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + $html .= '
'; + } + $html .= '
'; + } + return $html; + } + + private function render_legacy_fleet_table($nodes, $meshes) { + $public = rtrim((string)get_option('gp_mesh_public_url', 'https://mesh.christit.com'), '/'); + $mesh_map = []; + foreach((array)$meshes as $m) $mesh_map[$m['_id'] ?? $m['id'] ?? ''] = $m['name'] ?? 'Unknown'; + $html = ''; + foreach((array)$nodes as $n) { + if(!isset($n['name'])) continue; + $node_id = $n['id'] ?? $n['_id'] ?? ''; + $isOnline = isset($n['online']) ? (bool)$n['online'] : (isset($n['conn']) && $n['conn'] > 0); + $group_id = $n['groupId'] ?? $n['meshid'] ?? ''; + $groupName = $n['groupName'] ?? ($mesh_map[$group_id] ?? 'Unknown'); + $ip = $n['ip'] ?? $n['paddr'] ?? '---'; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + return $html . '
StatusDevice NameGroupIP AddressActions
'.($isOnline ? 'Online' : 'Offline').'' . esc_html($n['name']) . '' . esc_html($groupName) . '' . esc_html($ip) . ''; + if($node_id && $public) { + $safe_node = esc_attr($node_id); + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + $html .= '
'; + } + + public function ajax_dispatch_action() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(); + + $host = rtrim((string)get_option('gp_mesh_host', ''), '/'); + $key = (string)get_option('gp_mesh_key', ''); + $mode = get_option('gp_mesh_mode', 'bridge'); + + $payload = [ + 'node_id' => sanitize_text_field($_POST['node_id']), + 'action' => sanitize_text_field($_POST['action_type']), + 'message' => sanitize_text_field($_POST['message'] ?? '') + ]; + + if ($mode === 'legacy') { + $payload['key'] = $key; + $response = wp_remote_post($host . '/api/gracepress/action', [ + 'body' => wp_json_encode($payload), + 'headers' => ['Content-Type' => 'application/json'], + 'timeout' => 10 + ]); + } else { + $headers = ['Content-Type' => 'application/json']; + if ($key !== '') $headers['Authorization'] = 'Bearer ' . $key; + $response = wp_remote_post($host . '/api/action', [ + 'body' => wp_json_encode($payload), + 'headers' => $headers, + 'timeout' => 10 + ]); + } + + if (is_wp_error($response)) { + wp_send_json_error(['msg' => 'Failed to reach API']); + } + $code = wp_remote_retrieve_response_code($response); + $body = json_decode(wp_remote_retrieve_body($response), true); + if ($code >= 400 || (is_array($body) && empty($body['success']))) { + wp_send_json_error(['msg' => $body['error'] ?? $body['msg'] ?? 'Command was rejected by the bridge.']); + } + wp_send_json_success(); + } + + public function ajax_bridge_probe() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $probe = sanitize_key($_POST['probe'] ?? 'status'); + $paths = [ + 'health' => '/api/health', + 'version' => '/api/version', + 'status' => '/api/status', + 'selftest' => '/api/self-test', + 'groups' => '/api/groups?refresh=1', + 'devices' => '/api/devices?refresh=1', + 'users' => '/api/users?refresh=1', + 'events' => '/api/events?refresh=1&limit=50', + 'serverinfo' => '/api/serverinfo', + 'routes' => '/api/routes', + ]; + if (!isset($paths[$probe])) wp_send_json_error(['msg' => 'Unknown bridge probe.']); + + $response = $this->bridge_request($paths[$probe], ['timeout' => $probe === 'selftest' ? 35 : 20]); + $data = $this->bridge_json_response($response); + if (empty($data['success'])) { + if (function_exists('gp_log')) gp_log('MeshPress bridge probe failed.', 'GP-MESH', ['severity' => 'ERROR', 'probe' => $probe, 'error' => $data['error'] ?? 'Unknown']); + wp_send_json_error(['msg' => $data['error'] ?? 'Bridge probe failed.', 'data' => $data]); + } + if (function_exists('gp_log')) gp_log('MeshPress bridge probe completed.', 'GP-MESH', ['severity' => 'INFO', 'probe' => $probe]); + wp_send_json_success($data); + } + + public function ajax_update_bridge() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $data = $this->bridge_json_response($this->bridge_post('/api/update-now', [], ['timeout' => 12])); + if (empty($data['success'])) { + if (function_exists('gp_log')) gp_log('MeshPress bridge update trigger failed.', 'GP-MESH', ['severity' => 'ERROR', 'error' => $data['error'] ?? 'Unknown']); + wp_send_json_error(['msg' => $data['error'] ?? 'Bridge update trigger failed.', 'data' => $data]); + } + if (function_exists('gp_log')) gp_log('MeshPress bridge update manually triggered.', 'GP-MESH', ['severity' => 'INFO']); + wp_send_json_success($data); + } + + public function ajax_create_group() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $name = sanitize_text_field($_POST['group_name'] ?? ''); + $description = sanitize_text_field($_POST['group_description'] ?? ''); + if ($name === '') wp_send_json_error(['msg' => 'Group name is required.']); + + $response = $this->bridge_post('/api/groups/create', ['name' => $name, 'description' => $description]); + $data = $this->bridge_json_response($response); + if (empty($data['success'])) { + if (function_exists('gp_log')) gp_log('MeshPress group creation failed.', 'GP-MESH', ['severity' => 'ERROR', 'error' => $data['error'] ?? 'Unknown']); + wp_send_json_error(['msg' => $data['error'] ?? 'Could not create MeshCentral group.', 'data' => $data]); + } + $this->cache_clear(); + if (function_exists('gp_log')) gp_log('MeshPress created MeshCentral group.', 'GP-MESH', ['severity' => 'SUCCESS', 'group' => $data['group']['id'] ?? '']); + wp_send_json_success($data); + } + + public function ajax_agent_links() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $group_id = $this->sanitize_mesh_id($_POST['group_id'] ?? ''); + if ($group_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral group first.']); + + $response = $this->bridge_request('/api/agent-links/' . rawurlencode($group_id)); + $data = $this->bridge_json_response($response); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Could not generate agent links.', 'data' => $data]); + wp_send_json_success($data); + } + + public function ajax_device_links() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? ''); + if ($node_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral device first.']); + + $links = []; + $session = $this->bridge_json_response($this->bridge_request('/api/session-links/' . rawurlencode($node_id))); + if (!empty($session['success']) && is_array($session['links'] ?? null)) { + $links = $session['links']; + } else { + foreach (['desktop', 'console', 'files'] as $mode) { + $response = $this->bridge_request('/api/link/' . $mode . '/' . rawurlencode($node_id) . '?link_mode=all'); + $data = $this->bridge_json_response($response); + if (!empty($data['success'])) $links[$mode] = $data['links'] ?? $data; + } + } + if (!$links) wp_send_json_error(['msg' => 'Could not generate device links.']); + wp_send_json_success(['links' => $links]); + } + + public function ajax_device_info() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? ''); + if ($node_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral device first.']); + + $response = $this->bridge_request('/api/device/' . rawurlencode($node_id), ['timeout' => 25]); + $data = $this->bridge_json_response($response); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Could not read device info.', 'data' => $data]); + wp_send_json_success($data); + } + + public function ajax_session_diagnostics() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? ''); + if ($node_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral device first.']); + + $response = $this->bridge_request('/api/session-diagnostics/' . rawurlencode($node_id), ['timeout' => 30]); + $data = $this->bridge_json_response($response); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Could not build session diagnostics.', 'data' => $data]); + wp_send_json_success($data); + } + + public function ajax_resolve_group() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $group_id = $this->sanitize_mesh_id($_POST['group_id'] ?? ''); + if ($group_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral group first.']); + + $response = $this->bridge_request('/api/groups/resolve/' . rawurlencode($group_id), ['timeout' => 25]); + $data = $this->bridge_json_response($response); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Could not resolve group.', 'data' => $data]); + wp_send_json_success($data); + } + + public function ajax_edit_group() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $group_id = $this->sanitize_mesh_id($_POST['group_id'] ?? ''); + $name = sanitize_text_field($_POST['group_name'] ?? ''); + $description = sanitize_text_field($_POST['group_description'] ?? ''); + if ($group_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral group first.']); + if ($name === '' && $description === '') wp_send_json_error(['msg' => 'Enter a new group name or description.']); + + $data = $this->bridge_json_response($this->bridge_post('/api/groups/edit', ['id' => $group_id, 'name' => $name, 'description' => $description])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Group edit failed.', 'data' => $data]); + $this->cache_clear(); + wp_send_json_success($data); + } + + public function ajax_rename_device() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? ''); + $name = sanitize_text_field($_POST['device_name'] ?? ''); + if ($node_id === '' || $name === '') wp_send_json_error(['msg' => 'Choose a device and enter a new name.']); + + $data = $this->bridge_json_response($this->bridge_post('/api/device/rename', ['node_id' => $node_id, 'name' => $name])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Device rename failed.', 'data' => $data]); + $this->cache_clear(); + wp_send_json_success($data); + } + + public function ajax_move_device() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? ''); + $group_id = $this->sanitize_mesh_id($_POST['group_id'] ?? ''); + if ($node_id === '' || $group_id === '') wp_send_json_error(['msg' => 'Choose a device and destination group.']); + + $data = $this->bridge_json_response($this->bridge_post('/api/device/move', ['node_id' => $node_id, 'group_id' => $group_id])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Device move failed.', 'data' => $data]); + $this->cache_clear(); + wp_send_json_success($data); + } + + public function ajax_uninstall_agent() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? ''); + if ($node_id === '') wp_send_json_error(['msg' => 'Choose a device first.']); + + $data = $this->bridge_json_response($this->bridge_post('/api/device/uninstall', ['node_id' => $node_id], ['timeout' => 35])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Agent uninstall failed.', 'data' => $data]); + $this->cache_clear(); + wp_send_json_success($data); + } + + public function ajax_maintenance_probe() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $data = $this->bridge_json_response($this->bridge_request('/api/meshcentral/maintenance', ['timeout' => 35])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'MeshCentral maintenance probe failed.', 'data' => $data]); + wp_send_json_success($data); + } + + public function ajax_toggle_maintenance() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $enabled = !empty($_POST['enabled']) && $_POST['enabled'] !== '0' && $_POST['enabled'] !== 'false'; + $data = $this->bridge_json_response($this->bridge_post('/api/maintenance/toggle', ['enabled' => $enabled], ['timeout' => 20])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Maintenance toggle failed.', 'data' => $data]); + if (function_exists('gp_log')) { + gp_log($enabled ? 'MeshPress bridge maintenance mode enabled.' : 'MeshPress bridge maintenance mode disabled.', 'GP-MESH', ['severity' => 'INFO']); + } + wp_send_json_success($data); + } + + public function ajax_toggle_meshcentral_maintenance() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $enabled = !empty($_POST['enabled']) && $_POST['enabled'] !== '0' && $_POST['enabled'] !== 'false'; + $restart = empty($_POST['no_restart']); + $data = $this->bridge_json_response($this->bridge_post('/api/meshcentral/maintenance/toggle', [ + 'enabled' => $enabled, + 'restart' => $restart, + ], ['timeout' => 40])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'MeshCentral maintenance toggle failed.', 'data' => $data]); + if (function_exists('gp_log')) { + gp_log($enabled ? 'MeshCentral maintenance mode enabled from MeshPress.' : 'MeshCentral maintenance mode disabled from MeshPress.', 'GP-MESH', ['severity' => 'INFO']); + } + wp_send_json_success($data); + } + + public function ajax_meshcentral_config_audit() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $data = $this->bridge_json_response($this->bridge_request('/api/meshcentral/config-audit', ['timeout' => 35])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'MeshCentral config audit failed.', 'data' => $data]); + wp_send_json_success($data); + } + + public function ajax_meshcentral_backup() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $stop_service = !empty($_POST['stop_service']) && $_POST['stop_service'] !== '0' && $_POST['stop_service'] !== 'false'; + $data = $this->bridge_json_response($this->bridge_post('/api/meshcentral/backup', ['stop_service' => $stop_service], ['timeout' => 90])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'MeshCentral backup failed.', 'data' => $data]); + wp_send_json_success($data); + } + + public function ajax_meshcentral_update() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $data = $this->bridge_json_response($this->bridge_post('/api/meshcentral/update', [], ['timeout' => 20])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'MeshCentral update failed.', 'data' => $data]); + wp_send_json_success($data); + } + + public function ajax_clear_cache() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + $this->cache_clear(); + $this->bridge_post('/api/cache/clear', []); + if (function_exists('gp_log')) gp_log('MeshPress fleet cache cleared.', 'GP-MESH', ['severity' => 'INFO']); + wp_send_json_success(['message' => 'MeshPress cache cleared.']); + } + + public function ajax_push_now() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + $data = $this->bridge_json_response($this->bridge_post('/api/push-now', [])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Bridge push failed.', 'data' => $data]); + wp_send_json_success($data); + } + + public function ajax_health_check() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $checks = []; + $status = $this->bridge_json_response($this->bridge_request('/api/status', ['timeout' => 20])); + $selftest = $this->bridge_json_response($this->bridge_request('/api/self-test', ['timeout' => 35])); + $fleet = $this->bridge_json_response($this->bridge_request('/api/fleet?refresh=1', ['timeout' => 35])); + $last_event = get_transient('gp_mesh_last_bridge_event'); + $cached = get_transient('gp_mesh_fleet_cache'); + + $checks[] = [ + 'key' => 'bridge', + 'label' => 'WordPress to Bridge', + 'ok' => !empty($status['success']), + 'detail' => !empty($status['success']) ? 'Bridge reachable on ' . ($status['bind'] ?? '?') . ':' . ($status['port'] ?? '?') : ($status['error'] ?? 'Bridge unavailable.') + ]; + $checks[] = [ + 'key' => 'meshctrl', + 'label' => 'Bridge to MeshCentral', + 'ok' => !empty($selftest['success']), + 'detail' => !empty($selftest['success']) ? 'MeshCtrl, auth, groups, and devices passed self-test.' : ($selftest['error'] ?? 'Self-test failed.') + ]; + $checks[] = [ + 'key' => 'public_url', + 'label' => 'Public MeshCentral URL', + 'ok' => !empty($status['public_mesh_url_configured']), + 'detail' => $status['public_mesh_url'] ?? 'PUBLIC_MESH_URL is not configured.' + ]; + $checks[] = [ + 'key' => 'fleet', + 'label' => 'Fleet Freshness', + 'ok' => !empty($fleet['success']), + 'detail' => !empty($fleet['success']) ? count((array)($fleet['devices'] ?? [])). ' devices, ' . count((array)($fleet['groups'] ?? [])) . ' groups.' : ($fleet['error'] ?? 'Fleet refresh failed.') + ]; + $checks[] = [ + 'key' => 'push', + 'label' => 'Bridge Push', + 'ok' => !empty($status['wp_push_configured']), + 'detail' => !empty($status['wp_push_configured']) ? 'Push configured for ' . ($status['wp_domain'] ?? 'WordPress') : 'WP_DOMAIN/WP_API_KEY are not configured on the bridge.' + ]; + $checks[] = [ + 'key' => 'omnilog', + 'label' => 'OmniLog Events', + 'ok' => is_array($last_event), + 'detail' => is_array($last_event) ? 'Last event: ' . ($last_event['event'] ?? 'unknown') . ' at ' . ($last_event['received_at'] ?? 'unknown') : 'No bridge event has been received yet.' + ]; + + if (!empty($fleet['success'])) { + set_transient('gp_mesh_fleet_cache', [ + 'groups' => (array)($fleet['groups'] ?? []), + 'devices' => (array)($fleet['devices'] ?? []), + 'synced_at' => current_time('mysql'), + 'source' => 'health_check', + 'bridge_version' => sanitize_text_field($fleet['bridge_version'] ?? '') + ], DAY_IN_SECONDS); + set_transient('gp_mesh_nodes_cache', (array)($fleet['devices'] ?? []), DAY_IN_SECONDS); + } + + if (function_exists('gp_log')) gp_log('MeshPress connection health check completed.', 'GP-MESH', ['severity' => 'INFO']); + wp_send_json_success(['checks' => $checks, 'status' => $status, 'selftest' => $selftest, 'fleet' => $fleet, 'cached' => $cached]); + } + + public function ajax_wait_enrollment() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']); + + $group_id = $this->sanitize_mesh_id($_POST['group_id'] ?? ''); + if ($group_id === '') wp_send_json_error(['msg' => 'Choose a group first.']); + + $data = $this->bridge_json_response($this->bridge_request('/api/fleet?refresh=1', ['timeout' => 35])); + if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Fleet refresh failed.', 'data' => $data]); + + $devices = (array)($data['devices'] ?? []); + $matches = []; + $wanted_short = basename(str_replace('\\', '/', $group_id)); + foreach ($devices as $device) { + $device_group = (string)($device['groupId'] ?? $device['meshid'] ?? ''); + $device_short = basename(str_replace('\\', '/', $device_group)); + if ($device_group === $group_id || $device_short === $wanted_short) { + $matches[] = $device; + } + } + + set_transient('gp_mesh_fleet_cache', [ + 'groups' => (array)($data['groups'] ?? []), + 'devices' => $devices, + 'synced_at' => current_time('mysql'), + 'source' => 'enrollment_wait', + 'bridge_version' => sanitize_text_field($data['bridge_version'] ?? '') + ], DAY_IN_SECONDS); + set_transient('gp_mesh_nodes_cache', $devices, DAY_IN_SECONDS); + if (function_exists('gp_log')) gp_log('MeshPress enrollment check completed.', 'GP-MESH', ['severity' => $matches ? 'SUCCESS' : 'INFO', 'matches' => count($matches)]); + wp_send_json_success(['matches' => $matches, 'count' => count($matches), 'devices' => $devices, 'groups' => (array)($data['groups'] ?? [])]); + } + + public function ajax_get_fleet() { + check_ajax_referer('gp_mesh_nonce', 'nonce'); + if (!current_user_can('manage_options')) wp_send_json_error(); + + $host = rtrim((string)get_option('gp_mesh_host', ''), '/'); + $key = (string)get_option('gp_mesh_key', ''); + $mode = get_option('gp_mesh_mode', 'bridge'); + $public = rtrim((string)get_option('gp_mesh_public_url', ''), '/'); + + if (empty($host) || empty($key)) { + wp_send_json_success(['html' => '
Bridge URL or token missing. Go to Connection Setup.
']); + } + + if ($mode === 'legacy') { + $response = wp_remote_get($host . '/api/gracepress/fleet?key=' . urlencode($key), ['timeout' => 15]); + } else { + $response = $this->bridge_request('/api/fleet?refresh=1'); + } + + if (is_wp_error($response)) { + if (function_exists('gp_log')) gp_log("MeshPress Bridge unreachable: " . $response->get_error_message(), 'GP-MESH', ['severity' => 'ERROR']); + $cached = get_transient('gp_mesh_fleet_cache'); + if (is_array($cached) && !empty($cached['devices'])) { + wp_send_json_success(['html' => '
Showing pushed fleet snapshot from ' . esc_html($cached['synced_at'] ?? 'cache') . '.
' . $this->render_fleet_table($cached['devices'], $cached['groups'] ?? [])]); + } + wp_send_json_success(['html' => '
Failed to connect to MeshPress Bridge. Check URL, token, and firewall/Tailscale reachability.
']); + } + + $body = wp_remote_retrieve_body($response); + $data = json_decode($body, true); + + if (!$data || empty($data['success'])) { + if (function_exists('gp_log')) gp_log("MeshPress Bridge rejected request or returned invalid JSON.", 'GP-MESH', ['severity' => 'ERROR', 'response' => substr($body, 0, 500)]); + wp_send_json_success(['html' => '
Bridge rejected the request: ' . esc_html($data['error'] ?? 'Invalid response') . '
']); + } + + $nodes = $data['devices'] ?? $data['nodes'] ?? []; + $meshes = $data['groups'] ?? $data['meshes'] ?? []; + + if (empty($nodes)) { + wp_send_json_success(['html' => '
No devices found in MeshCentral database.
']); + } + + set_transient('gp_mesh_fleet_cache', ['groups' => $meshes, 'devices' => $nodes, 'synced_at' => current_time('mysql')], DAY_IN_SECONDS); + set_transient('gp_mesh_nodes_cache', $nodes, DAY_IN_SECONDS); + if (function_exists('gp_log')) gp_log("MeshCentral Fleet Synced Successfully via MeshPress Bridge.", 'GP-MESH', ['severity' => 'SUCCESS', 'devices' => count($nodes), 'groups' => count($meshes)]); + + wp_send_json_success(['html' => $this->render_fleet_table($nodes, $meshes)]); + } + + // ===================================================================== + // 4. ADMIN DASHBOARD UI + // ===================================================================== + public function register_admin_page() { + $slug = 'mesh-press'; + $title = 'MeshPress Sync'; + global $admin_page_hooks; + if (isset($admin_page_hooks['service-desk'])) { + add_submenu_page('service-desk', 'MeshPress', $title, 'manage_options', $slug, [$this, 'admin_ui']); + } elseif (class_exists('GracePress_Central_Hub')) { + add_submenu_page('gracepress-central', 'MeshPress', $title, 'manage_options', $slug, [$this, 'admin_ui']); + } else { + add_menu_page('MeshPress', $title, 'manage_options', $slug, [$this, 'admin_ui'], 'dashicons-admin-site-alt3', 6); + } + } + + public function admin_ui() { + if (!current_user_can('manage_options')) return; + $host = get_option('gp_mesh_host', ''); + $key = get_option('gp_mesh_key', ''); + $public_url = get_option('gp_mesh_public_url', 'https://mesh.christit.com'); + $mode = get_option('gp_mesh_mode', 'bridge'); + $link_mode = get_option('gp_mesh_link_mode', 'gotonode'); + $advanced_mode = get_option('gp_mesh_advanced_mode', 'no') === 'yes'; + $last_event = get_transient('gp_mesh_last_bridge_event'); + $cached_fleet = get_transient('gp_mesh_fleet_cache'); + $cached_groups = is_array($cached_fleet['groups'] ?? null) ? $cached_fleet['groups'] : []; + $cached_devices = is_array($cached_fleet['devices'] ?? null) ? $cached_fleet['devices'] : []; + ?> + + +
+ +
+
+

GracePress MeshPress Sync

+

Enterprise RMM bridge. Communicates with the GracePress MeshPress Bridge for MeshCentral fleet syncing.

+
+ +
+
Fleet Overview & Actions
+
MeshCentral Ops
+
Troubleshooting
+
Connection Setup
+
Deploy Agents
+
+ +
+
+
+
+

Connection Health

+

Checks the bridge, MeshCentral, public session URL, fleet cache, push settings, and OmniLog event flow.

+
+ +
+
+
StatusNot checked
+ +
Last OmniLog Event
+ +
+
+ Raw health output +
Run a health check to collect diagnostics.
+
+
+ +
+
+

Managed Devices

+ +
+
+
+ +
+
+
+
+ +
+
+
+

MeshCentral Runtime

+

Reads MeshCentral package/runtime details through the bridge. Bridge maintenance unlocks guarded bridge actions; MeshCentral maintenance writes settings.maintenanceMode into MeshCentral config and restarts the service.

+
+ + + + + + +
+
No MeshCentral maintenance probe has been run yet.
+
+ +
+

MeshCentral Backups & Config Audit

+

Creates a full snapshot of MeshCentral data/config and, when MySQL or MariaDB is configured, includes a database dump. The audit output is redacted for copy/paste diagnostics.

+
+ + + +
+
Backup tools are intentionally guarded. Run status first to confirm bridge maintenance settings.
+
+
+ +
+

Agent Lifecycle

+

Manual uninstall diagnostics for a selected MeshCentral agent. Keep this as an operator-only test until we prove the exact MeshCtrl command on your MeshCentral version.

+
+ + +
+
No lifecycle action has been run yet.
+
+
+ +
+
+

Bridge Runtime Status

+

Check the live bridge version, install path, updater availability, and trigger the catalog updater manually.

+
+
StatusNot checked
+
+
+ + +
+
+
+ +
+
+

Bridge Probes

+

Run safe checks against the local bridge before wiring actions into Service Desk.

+
+ + + + + + + + + + + + +
+
Run a probe to see raw bridge results. Bridge logs now use copy-friendly JSON lines prefixed with [meshpress-bridge]. Useful events: session_links, session_diagnostics, group_resolve, group_edit_attempt, group_edit_success, group_edit_failed.
+
+ +
+

Create MeshCentral Group

+

This requires MESHPRESS_ALLOW_COMMANDS=1 on the bridge.

+ + + +
+
+
+ +
+
+

Edit MeshCentral Group

+

Rename or update a group description, then clear/push fleet cache.

+ + + + + +
+
+ +
+

Agent Link Generator

+

Choose a MeshCentral device group, then copy a Linux install command or open a platform download. MeshPress fills the Mesh ID automatically; you should not have to paste it manually.

+ + +
+
+ +
+

Device Link Tester

+

Generates Desktop, Terminal, and Files URLs in every supported MeshCentral direct-link pattern. Start with Terminal > primary, then try alternates if your MeshCentral build prefers another parameter order.

+ + + + + + +
+ + +
+
+ + +
+
+
+
+
+ +
+
+
+

New Server Onboarding

+
+
Bridge URLPoint WordPress at the bridge running beside the new MeshCentral server.
+
Bridge TokenUse the token from /etc/gracepress/meshpress-bridge.env.
+
Public MeshCentral URLThis URL is used for Desktop, Terminal, Files, and agent installer links.
+
4
Bridge Self-TestAfter saving, run the health check to confirm MeshCtrl path, auth, groups, devices, push, and OmniLog.
+
+ + + + + + + + + + + + + + + + + +
+ +
+

Required Configuration

+

This plugin now talks to the catalog-managed MeshPress Bridge service. The bridge runs beside MeshCentral and uses MeshCtrl to read groups and devices.

+
+
1
+
Install the Bridge on the New MeshCentral Server
Use tools/meshpress-bridge/install.sh, then configure /etc/gracepress/meshpress-bridge.env.
+
+
+
2
+
Expose the Bridge Safely
Prefer localhost plus private proxy/VPN, or bind to a private address with strict firewall and bridge token controls.
+
+
+
3
+
Sync the Fleet
Save the bridge URL, token, and public MeshCentral URL, then run Sync API Data.
+
+
+
+
+ +
+
+

Deploy New Agents

+

Choose a MeshCentral group, generate platform links, then copy the Linux command to install the agent on Victor or another server.

+
+ +
+
1
+
Linux install flow
Generate links, copy linux_x64_install_command, paste it into Victor, and then sync the fleet.
+
+
+
+ + + +
+
Generate an installer, run it on the new machine, then check for the agent in the selected group.
+
+
+
+
+ + + checked)) return $t; + $s = 'mesh-press/mesh-press.php'; + if (!function_exists('get_plugin_data')) include_once(ABSPATH . 'wp-admin/includes/plugin.php'); + $p = file_exists(WP_PLUGIN_DIR . '/' . $s) ? get_plugin_data(WP_PLUGIN_DIR . '/' . $s) : []; + $r = wp_remote_get('https://christit.com/gracepress/api.php?endpoint=manifest&slug=mesh-press&nocache=' . time(), ['timeout' => 10]); + if (!is_wp_error($r) && wp_remote_retrieve_response_code($r) == 200) { + $d = json_decode(wp_remote_retrieve_body($r)); + if ($d && version_compare(trim($p['Version'] ?? '0.0.0'), trim($d->version ?? '0.0.0'), '<')) { + $x = new stdClass(); + $x->slug = dirname($s); $x->plugin = $s; $x->new_version = $d->version; + $x->package = $d->download_url; $x->url = 'https://christit.com/gracepress/'; + $x->tested = '6.4'; $x->requires = '6.0'; $x->requires_php = '8.0'; + $t->response[$s] = $x; + } + } + return $t; + } + } + new GP_Mesh_UE(); +}