Fix federation flows and add versioned updater

This commit is contained in:
cyph3rasi
2026-03-07 21:55:55 -08:00
parent 5d524a0354
commit 1b0b3d8d52
24 changed files with 1486 additions and 142 deletions
+5
View File
@@ -39,6 +39,11 @@ DB_NAME=synapsis
# Existing reverse proxy mode (PROXY=none): use a fixed localhost port, e.g. PORT=3000.
PORT=auto
# Host updater integration for the admin panel.
# These are generated by the installer for Docker installs.
# HOST_UPDATER_SOCKET=/var/run/synapsis-updater/updater.sock
# HOST_UPDATER_TOKEN=
# Allow localhost domains in production (set only for local testing)
# ALLOW_LOCALHOST=1
+30 -1
View File
@@ -33,8 +33,21 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set environment variables for build
ARG APP_VERSION=dev
ARG APP_COMMIT=unknown
ARG APP_BUILD_DATE=
ARG APP_GITHUB_URL=
ARG APP_IMAGE_REPO=ghcr.io/gnosyslabs/synapsis
ARG APP_SOURCE_REPO=https://github.com/GnosysLabs/Synapsis
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV APP_VERSION=${APP_VERSION}
ENV APP_COMMIT=${APP_COMMIT}
ENV APP_BUILD_DATE=${APP_BUILD_DATE}
ENV APP_GITHUB_URL=${APP_GITHUB_URL}
ENV APP_IMAGE_REPO=${APP_IMAGE_REPO}
ENV APP_SOURCE_REPO=${APP_SOURCE_REPO}
# Build the Next.js application
RUN npm run build
@@ -47,8 +60,18 @@ RUN apk add --no-cache libc6-compat openssl netcat-openbsd wget
WORKDIR /app
LABEL org.opencontainers.image.source="https://github.com/GnosysLabs/Synapsis"
ARG APP_VERSION=dev
ARG APP_COMMIT=unknown
ARG APP_BUILD_DATE=
ARG APP_GITHUB_URL=
ARG APP_IMAGE_REPO=ghcr.io/gnosyslabs/synapsis
ARG APP_SOURCE_REPO=https://github.com/GnosysLabs/Synapsis
LABEL org.opencontainers.image.source="${APP_SOURCE_REPO}"
LABEL org.opencontainers.image.description="Synapsis self-hosted social node"
LABEL org.opencontainers.image.version="${APP_VERSION}"
LABEL org.opencontainers.image.revision="${APP_COMMIT}"
LABEL org.opencontainers.image.created="${APP_BUILD_DATE}"
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs && \
@@ -62,6 +85,12 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV APP_VERSION=${APP_VERSION}
ENV APP_COMMIT=${APP_COMMIT}
ENV APP_BUILD_DATE=${APP_BUILD_DATE}
ENV APP_GITHUB_URL=${APP_GITHUB_URL}
ENV APP_IMAGE_REPO=${APP_IMAGE_REPO}
ENV APP_SOURCE_REPO=${APP_SOURCE_REPO}
# Copy public assets
COPY --from=builder /app/public ./public
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
import json
import os
import signal
import socketserver
import subprocess
import threading
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler
SOCKET_PATH = os.environ.get("HOST_UPDATER_SOCKET", "/var/run/synapsis-updater/updater.sock")
TOKEN = os.environ.get("HOST_UPDATER_TOKEN", "")
STATUS_FILE = os.environ.get("HOST_UPDATER_STATUS_FILE", "/opt/synapsis/updater-status.json")
LOG_FILE = os.environ.get("HOST_UPDATER_LOG_FILE", "/opt/synapsis/updater.log")
UPDATE_SCRIPT = os.environ.get("HOST_UPDATER_SCRIPT", "/opt/synapsis/update-local.sh")
INSTALL_DIR = os.environ.get("INSTALL_DIR", "/opt/synapsis")
status_lock = threading.Lock()
current_process = None
def now_iso():
return datetime.now(timezone.utc).isoformat()
def ensure_parent_dirs():
os.makedirs(os.path.dirname(SOCKET_PATH), exist_ok=True)
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
def load_status():
if not os.path.exists(STATUS_FILE):
return {
"available": True,
"status": "idle",
"message": "Ready to update.",
"lastStartedAt": None,
"lastFinishedAt": None,
"lastExitCode": None,
"lastError": None,
"pid": None,
}
with open(STATUS_FILE, "r", encoding="utf-8") as handle:
return json.load(handle)
def save_status(status):
with open(STATUS_FILE, "w", encoding="utf-8") as handle:
json.dump(status, handle, indent=2)
def update_status(**changes):
with status_lock:
status = load_status()
status.update(changes)
save_status(status)
return status
def is_authorized(headers):
expected = f"Bearer {TOKEN}"
return bool(TOKEN) and headers.get("Authorization", "") == expected
def watch_process(process):
exit_code = process.wait()
update_status(
status="success" if exit_code == 0 else "error",
message="Synapsis update completed." if exit_code == 0 else "Synapsis update failed.",
lastFinishedAt=now_iso(),
lastExitCode=exit_code,
lastError=None if exit_code == 0 else f"Updater exited with code {exit_code}",
pid=None,
)
class ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
class Handler(BaseHTTPRequestHandler):
server_version = "SynapsisHostUpdater/1.0"
def log_message(self, format, *args):
return
def send_json(self, status_code, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status_code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
self.send_json(HTTPStatus.OK, {"ok": True})
return
if self.path == "/status":
if not is_authorized(self.headers):
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"})
return
self.send_json(HTTPStatus.OK, load_status())
return
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
def do_POST(self):
global current_process
if self.path != "/update":
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
return
if not is_authorized(self.headers):
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"})
return
with status_lock:
if current_process and current_process.poll() is None:
self.send_json(
HTTPStatus.CONFLICT,
{
"ok": False,
"status": "updating",
"message": "An update is already running.",
},
)
return
log_handle = open(LOG_FILE, "a", encoding="utf-8")
current_process = subprocess.Popen(
[UPDATE_SCRIPT],
cwd=INSTALL_DIR,
stdout=log_handle,
stderr=subprocess.STDOUT,
start_new_session=True,
env={
**os.environ,
"INSTALL_DIR": INSTALL_DIR,
},
)
update_status(
status="updating",
message="Synapsis update started.",
lastStartedAt=now_iso(),
lastFinishedAt=None,
lastExitCode=None,
lastError=None,
pid=current_process.pid,
)
thread = threading.Thread(target=watch_process, args=(current_process,), daemon=True)
thread.start()
self.send_json(
HTTPStatus.ACCEPTED,
{
"ok": True,
"status": "updating",
"message": "Synapsis update started.",
},
)
def cleanup_socket(*_args):
try:
if os.path.exists(SOCKET_PATH):
os.remove(SOCKET_PATH)
finally:
raise SystemExit(0)
def main():
ensure_parent_dirs()
if os.path.exists(SOCKET_PATH):
os.remove(SOCKET_PATH)
signal.signal(signal.SIGTERM, cleanup_socket)
signal.signal(signal.SIGINT, cleanup_socket)
update_status(available=True)
with ThreadedUnixServer(SOCKET_PATH, Handler) as server:
os.chmod(SOCKET_PATH, 0o666)
server.serve_forever()
if __name__ == "__main__":
main()
+122
View File
@@ -7,6 +7,7 @@ REF="${REF:-main}"
INSTALL_DIR="${1:-${INSTALL_DIR:-/opt/synapsis}}"
PUBLIC_INSTALL_URL="${PUBLIC_INSTALL_URL:-https://synapsis.social/install.sh}"
PROXY="${PROXY:-caddy}"
HOST_UPDATER_SOCKET_PATH="${HOST_UPDATER_SOCKET_PATH:-/var/run/synapsis-updater/updater.sock}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
@@ -79,6 +80,19 @@ set_env_value() {
rm -f "${file}.bak"
}
ensure_env_value() {
file="$1"
key="$2"
value="$3"
if grep -q "^${key}=" "$file" 2>/dev/null; then
set_env_value "$file" "$key" "$value"
return
fi
printf '%s=%s\n' "$key" "$value" >> "$file"
}
get_env_value() {
file="$1"
key="$2"
@@ -94,6 +108,15 @@ generate_db_password() {
openssl rand -base64 24 | tr -d '\n' | tr '/+' '_-' | cut -c1-32
}
generate_token() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
return
fi
date +%s | sha256sum | awk '{print $1}'
}
find_available_port() {
start="${1:-3000}"
end="${2:-3020}"
@@ -153,6 +176,103 @@ install_docker_if_needed() {
fi
}
install_python3_if_needed() {
if command -v python3 >/dev/null 2>&1; then
return
fi
if command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y python3
return
fi
if command -v apk >/dev/null 2>&1; then
apk add --no-cache python3
return
fi
if command -v dnf >/dev/null 2>&1; then
dnf install -y python3
return
fi
if command -v yum >/dev/null 2>&1; then
yum install -y python3
return
fi
echo "⚠️ Could not install python3 automatically. Host updater will be unavailable." >&2
}
install_host_updater() {
if [ "$(id -u)" -ne 0 ]; then
echo "⚠️ Host updater installation requires root. Skipping one-click updater setup."
return
fi
download_file "docker/host-updater.py" "${INSTALL_DIR}/host-updater.py"
download_file "docker/update-local.sh" "${INSTALL_DIR}/update-local.sh"
chmod 755 "${INSTALL_DIR}/host-updater.py" "${INSTALL_DIR}/update-local.sh"
mkdir -p "$(dirname "${HOST_UPDATER_SOCKET_PATH}")"
updater_token="$(get_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_TOKEN" || true)"
if [ -z "${updater_token}" ]; then
updater_token="$(generate_token)"
ensure_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_TOKEN" "${updater_token}"
fi
ensure_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_SOCKET" "${HOST_UPDATER_SOCKET_PATH}"
if ! command -v systemctl >/dev/null 2>&1; then
echo "⚠️ systemd not found. Admin one-click updates will be unavailable on this host."
return
fi
install_python3_if_needed
if ! command -v python3 >/dev/null 2>&1; then
echo "⚠️ python3 is unavailable. Admin one-click updates will be unavailable."
return
fi
cat > "${INSTALL_DIR}/updater.env" <<EOF
HOST_UPDATER_TOKEN=${updater_token}
REPO=${REPO}
REF=${REF}
EOF
chmod 600 "${INSTALL_DIR}/updater.env"
cat > /etc/systemd/system/synapsis-updater.service <<EOF
[Unit]
Description=Synapsis Host Updater
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=${INSTALL_DIR}
Environment=INSTALL_DIR=${INSTALL_DIR}
Environment=HOST_UPDATER_SOCKET=${HOST_UPDATER_SOCKET_PATH}
Environment=HOST_UPDATER_STATUS_FILE=${INSTALL_DIR}/updater-status.json
Environment=HOST_UPDATER_LOG_FILE=${INSTALL_DIR}/updater.log
Environment=HOST_UPDATER_SCRIPT=${INSTALL_DIR}/update-local.sh
EnvironmentFile=${INSTALL_DIR}/updater.env
ExecStart=/usr/bin/env python3 ${INSTALL_DIR}/host-updater.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now synapsis-updater.service >/dev/null 2>&1 || true
systemctl restart synapsis-updater.service >/dev/null 2>&1 || true
}
require_command curl
require_command chmod
require_command mkdir
@@ -228,6 +348,8 @@ else
fi
fi
install_host_updater
echo ""
echo "Next steps:"
echo " 1. Review ${INSTALL_DIR}/.env"
+8
View File
@@ -81,6 +81,14 @@ cleanup_named_resources() {
echo "🗑️ Removing network synapsis_synapsis-network"
docker network rm synapsis_synapsis-network >/dev/null 2>&1 || true
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now synapsis-updater.service >/dev/null 2>&1 || true
rm -f /etc/systemd/system/synapsis-updater.service
systemctl daemon-reload >/dev/null 2>&1 || true
fi
rm -rf /var/run/synapsis-updater
}
cleanup_images() {
+73
View File
@@ -0,0 +1,73 @@
#!/bin/sh
set -eu
REPO="${REPO:-GnosysLabs/Synapsis}"
REF="${REF:-main}"
INSTALL_DIR="${1:-${INSTALL_DIR:-/opt/synapsis}}"
RAW_BASE="https://raw.githubusercontent.com/${REPO}/${REF}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "❌ Required command not found: $1" >&2
exit 1
fi
}
download_file() {
source_path="$1"
target_path="$2"
echo "⬇️ Downloading ${source_path}"
curl -fsSL "${RAW_BASE}/${source_path}" -o "${target_path}"
}
detect_proxy_mode() {
if [ -f "${INSTALL_DIR}/Caddyfile" ] || [ -f "${INSTALL_DIR}/caddy-entrypoint.sh" ]; then
printf 'caddy\n'
return
fi
printf 'none\n'
}
compose_cmd() {
if [ -f "${INSTALL_DIR}/.env" ]; then
docker compose --env-file "${INSTALL_DIR}/.env" -f "${INSTALL_DIR}/docker-compose.yml" "$@"
else
docker compose -f "${INSTALL_DIR}/docker-compose.yml" "$@"
fi
}
require_command curl
require_command docker
require_command chmod
require_command mkdir
if [ ! -d "${INSTALL_DIR}" ]; then
echo "${INSTALL_DIR} does not exist." >&2
exit 1
fi
ACTIVE_PROXY="$(detect_proxy_mode)"
case "${ACTIVE_PROXY}" in
caddy)
download_file "docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml"
download_file "docker/Caddyfile" "${INSTALL_DIR}/Caddyfile"
download_file "docker/caddy-entrypoint.sh" "${INSTALL_DIR}/caddy-entrypoint.sh"
chmod 755 "${INSTALL_DIR}/caddy-entrypoint.sh"
;;
none)
download_file "docker-compose.proxyless.yml" "${INSTALL_DIR}/docker-compose.yml"
rm -f "${INSTALL_DIR}/Caddyfile" "${INSTALL_DIR}/caddy-entrypoint.sh"
;;
esac
echo "🐳 Pulling latest Synapsis image"
compose_cmd pull
echo "🚀 Restarting Synapsis"
compose_cmd up -d --remove-orphans
echo ""
echo "✅ Synapsis has been updated to the latest published image."
+237
View File
@@ -0,0 +1,237 @@
#!/bin/sh
set -eu
REPO="${REPO:-GnosysLabs/Synapsis}"
REF="${REF:-main}"
INSTALL_DIR="${1:-${INSTALL_DIR:-/opt/synapsis}}"
RAW_BASE="https://raw.githubusercontent.com/${REPO}/${REF}"
PROXY="${PROXY:-}"
HOST_UPDATER_SOCKET_PATH="${HOST_UPDATER_SOCKET_PATH:-/var/run/synapsis-updater/updater.sock}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "❌ Required command not found: $1" >&2
exit 1
fi
}
download_file() {
source_path="$1"
target_path="$2"
echo "⬇️ Downloading ${source_path}"
curl -fsSL "${RAW_BASE}/${source_path}" -o "${target_path}"
}
set_env_value() {
file="$1"
key="$2"
value="$3"
escaped_value=$(printf '%s\n' "$value" | sed -e 's/[\/&]/\\&/g')
sed -i.bak -E "s|^${key}=.*$|${key}=${escaped_value}|" "$file"
rm -f "${file}.bak"
}
ensure_env_value() {
file="$1"
key="$2"
value="$3"
if grep -q "^${key}=" "$file" 2>/dev/null; then
set_env_value "$file" "$key" "$value"
return
fi
printf '%s=%s\n' "$key" "$value" >> "$file"
}
get_env_value() {
file="$1"
key="$2"
sed -n -E "s/^${key}=(.*)$/\\1/p" "$file" | head -n 1
}
generate_token() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
return
fi
date +%s | sha256sum | awk '{print $1}'
}
normalize_proxy_mode() {
case "$1" in
caddy|none)
printf '%s\n' "$1"
;;
*)
echo "❌ Unsupported PROXY mode: $1" >&2
echo " Supported values: caddy, none" >&2
exit 1
;;
esac
}
detect_proxy_mode() {
if [ -n "${PROXY}" ]; then
normalize_proxy_mode "${PROXY}"
return
fi
if [ -f "${INSTALL_DIR}/Caddyfile" ] || [ -f "${INSTALL_DIR}/caddy-entrypoint.sh" ]; then
printf 'caddy\n'
return
fi
printf 'none\n'
}
install_python3_if_needed() {
if command -v python3 >/dev/null 2>&1; then
return
fi
if command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y python3
return
fi
if command -v apk >/dev/null 2>&1; then
apk add --no-cache python3
return
fi
}
install_host_updater() {
if [ "$(id -u)" -ne 0 ]; then
return
fi
if [ ! -f "${INSTALL_DIR}/.env" ]; then
return
fi
download_file "docker/host-updater.py" "${INSTALL_DIR}/host-updater.py"
download_file "docker/update-local.sh" "${INSTALL_DIR}/update-local.sh"
chmod 755 "${INSTALL_DIR}/host-updater.py" "${INSTALL_DIR}/update-local.sh"
mkdir -p "$(dirname "${HOST_UPDATER_SOCKET_PATH}")"
updater_token="$(get_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_TOKEN" || true)"
if [ -z "${updater_token}" ]; then
updater_token="$(generate_token)"
ensure_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_TOKEN" "${updater_token}"
fi
ensure_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_SOCKET" "${HOST_UPDATER_SOCKET_PATH}"
if ! command -v systemctl >/dev/null 2>&1; then
return
fi
install_python3_if_needed
if ! command -v python3 >/dev/null 2>&1; then
return
fi
cat > "${INSTALL_DIR}/updater.env" <<EOF
HOST_UPDATER_TOKEN=${updater_token}
REPO=${REPO}
REF=${REF}
EOF
chmod 600 "${INSTALL_DIR}/updater.env"
cat > /etc/systemd/system/synapsis-updater.service <<EOF
[Unit]
Description=Synapsis Host Updater
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=${INSTALL_DIR}
Environment=INSTALL_DIR=${INSTALL_DIR}
Environment=HOST_UPDATER_SOCKET=${HOST_UPDATER_SOCKET_PATH}
Environment=HOST_UPDATER_STATUS_FILE=${INSTALL_DIR}/updater-status.json
Environment=HOST_UPDATER_LOG_FILE=${INSTALL_DIR}/updater.log
Environment=HOST_UPDATER_SCRIPT=${INSTALL_DIR}/update-local.sh
EnvironmentFile=${INSTALL_DIR}/updater.env
ExecStart=/usr/bin/env python3 ${INSTALL_DIR}/host-updater.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now synapsis-updater.service >/dev/null 2>&1 || true
systemctl restart synapsis-updater.service >/dev/null 2>&1 || true
}
require_command curl
require_command docker
require_command chmod
require_command mkdir
if [ ! -d "${INSTALL_DIR}" ]; then
echo "${INSTALL_DIR} does not exist." >&2
echo " Run the installer first:" >&2
echo " curl -fsSL https://synapsis.social/install.sh | bash" >&2
exit 1
fi
if [ ! -f "${INSTALL_DIR}/docker-compose.yml" ]; then
echo "${INSTALL_DIR}/docker-compose.yml was not found." >&2
echo " This does not look like a Synapsis install directory." >&2
exit 1
fi
ACTIVE_PROXY="$(detect_proxy_mode)"
echo "========================================"
echo " Synapsis Docker Updater"
echo "========================================"
echo " Repo: ${REPO}"
echo " Ref: ${REF}"
echo " Install dir: ${INSTALL_DIR}"
echo " Proxy mode: ${ACTIVE_PROXY}"
echo "========================================"
mkdir -p "${INSTALL_DIR}"
download_file "docker/.env.example" "${INSTALL_DIR}/.env.example"
case "${ACTIVE_PROXY}" in
caddy)
download_file "docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml"
download_file "docker/Caddyfile" "${INSTALL_DIR}/Caddyfile"
download_file "docker/caddy-entrypoint.sh" "${INSTALL_DIR}/caddy-entrypoint.sh"
chmod 755 "${INSTALL_DIR}/caddy-entrypoint.sh"
;;
none)
download_file "docker-compose.proxyless.yml" "${INSTALL_DIR}/docker-compose.yml"
rm -f "${INSTALL_DIR}/Caddyfile" "${INSTALL_DIR}/caddy-entrypoint.sh"
;;
esac
install_host_updater
echo "🐳 Pulling latest Synapsis image"
if [ -f "${INSTALL_DIR}/.env" ]; then
docker compose --env-file "${INSTALL_DIR}/.env" -f "${INSTALL_DIR}/docker-compose.yml" pull
echo "🚀 Restarting Synapsis"
docker compose --env-file "${INSTALL_DIR}/.env" -f "${INSTALL_DIR}/docker-compose.yml" up -d --remove-orphans
else
docker compose -f "${INSTALL_DIR}/docker-compose.yml" pull
echo "🚀 Restarting Synapsis"
docker compose -f "${INSTALL_DIR}/docker-compose.yml" up -d --remove-orphans
fi
echo ""
echo "✅ Synapsis has been updated to the latest published image."