From dc388d0cf77951f432f05c7ce9ae65f21c953fc8 Mon Sep 17 00:00:00 2001 From: saint Date: Tue, 31 Dec 2024 00:03:06 +1100 Subject: [PATCH] Add calcom to Core for testing / rollout --- lib/calcom.sh | 1803 +++++++++++++++++++++++++++++++++++++++++-- lib/functions.sh | 2 +- lib/latest-versions | 1 + lib/pdns.sh | 6 +- lib/wireguard.sh | 1 + 5 files changed, 1762 insertions(+), 51 deletions(-) diff --git a/lib/calcom.sh b/lib/calcom.sh index 8b7a353..66cc0a4 100644 --- a/lib/calcom.sh +++ b/lib/calcom.sh @@ -3,81 +3,1790 @@ # Cal.com Service PATH=$HOME/.docker/cli-plugins:/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +get_appvars config_calcom() { echo -ne "\n* Configuring /federated/apps/calcom container.." - spin & - SPINPID=$! + # spin & + # SPINPID=$! if [ ! -d "/federated/apps/calcom" ]; then mkdir -p /federated/apps/calcom fi - DOMAIN_ARRAY=(${DOMAIN//./ }) - DOMAIN_FIRST=${DOMAIN_ARRAY[0]} - DOMAIN_LAST=${DOMAIN_ARRAY[1]} + CALCOM_SECRET=$(create_password); -cat > /federated/apps/calcom/docker-compose.yml < /federated/apps/calcom/docker-compose.yml </dev/null | md5sum | awk '{ print $1 }'` -# Fix up .env file for our DOMAIN -sed -i "s#NEXT_PUBLIC_WEBAPP_URL=.*#NEXT_PUBLIC_WEBAPP_URL=http://calcom.$DOMAIN:3000#g" /federated/apps/calcom/.env + cat > /federated/apps/calcom/.env </dev/null | md5sum | awk '{ print $1 }'` -sed -i "s#CALENDSO_ENCRYPTION_KEY=.*#CALENDSO_ENCRYPTION_KEY=$CALENDSO_ENCRYPTION_KEY#g" /federated/apps/calcom/.env - -sed -i "s#POSTGRES_USER=.*#POSTGRES_USER=calcom#g" /federated/apps/calcom/.env -sed -i "s#POSTGRES_PASSWORD=.*#POSTGRES_PASSWORD=$CALCOM_SECRET#g" /federated/apps/calcom/.env -sed -i "s#POSTGRES_DB=.*#POSTGRES_DB=calcom#g" /federated/apps/calcom/.env -sed -i "s#DATABASE_HOST=.*#DATABASE_HOST=postgresql\.$DOMAIN:5432#g" /federated/apps/calcom/.env -sed -i "s#DATABASE_URL=.*#DATABASE_URL=postgresql://calcom:$CALCOM_SECRET@postgresql\.$DOMAIN/calcom#g" /federated/apps/calcom/.env - -cat >> /federated/apps/calcom/.env < /dev/null -echo -ne "done." + + chmod 600 /federated/apps/calcom/.env + + # Make data and data/federated directories + mkdir -p /federated/apps/calcom/data/root/federated + + # Create .c source code for CRYPT SHA512 {crypt} Hash statically-linked mini-binary + cat > /federated/apps/calcom/data/root/federated/static_crypt.c <<'EOF' +#define _XOPEN_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +// Characters allowed in the salt +static const char salt_chars[] = + "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789" + "./"; + +// Generate a random salt of specified length +void generate_salt(char *salt, size_t length) { + // Initialize random number generator + static int seeded = 0; + if (!seeded) { + srand(time(NULL)); + seeded = 1; + } + + // Generate random salt + const size_t salt_chars_len = strlen(salt_chars); + for (size_t i = 0; i < length; i++) { + salt[i] = salt_chars[rand() % salt_chars_len]; + } + salt[length] = '\0'; +} + +int main(int argc, char *argv[]) { + if (argc < 2 || argc > 3) { + fprintf(stderr, "Usage: %s [salt]\n", argv[0]); + return 1; + } + + const char *password = argv[1]; + char salt_buffer[256]; + const char *result; + + if (argc == 2) { + // Generate random 8-character salt + char random_salt[9]; + generate_salt(random_salt, 8); + snprintf(salt_buffer, sizeof(salt_buffer), "$6$%s", random_salt); + } else { + // Ensure length of arg plus length of prefix does not exceed size of salt_buffer + assert(strlen(argv[2]) + 4 < sizeof(salt_buffer)); + // Use provided salt, ensuring it starts with $6$ + if (strncmp(argv[2], "$6$", 3) != 0) { + snprintf(salt_buffer, sizeof(salt_buffer), "$6$%s", argv[2]); + } else { + strncpy(salt_buffer, argv[2], sizeof(salt_buffer)-1); + salt_buffer[sizeof(salt_buffer)-1] = '\0'; // Ensure null termination + } + } + + result = crypt(password, salt_buffer); + if (result == NULL) { + fprintf(stderr, "crypt() failed\n"); + return 1; + } + + printf("%s", result); + return 0; +} +EOF + + # Build .c into local statically linked binary with local glibc and gcc + apt update + apt install -y gcc libcrypt-dev + gcc -static -Os -o /federated/apps/calcom/data/root/federated/static_crypt /federated/apps/calcom/data/root/federated/static_crypt.c -lcrypt + + # Add script for applying SHA512 patches into the already built cal.com .js files + cat > /federated/apps/calcom/data/root/federated/modify-hash-crypt-sha512.sh <<'EOOF' +#!/bin/bash + +# Function to backup a file before modifying +backup_file() { + cp "$1" "$1.bak" + echo "Created backup of $1" +} + +DIRS=( + "/calcom/apps/web/.next/server/chunks" + "/calcom/apps/web/.next/server/pages/api" + "/calcom/apps/web/.next/standalone/apps/web/.next/server/chunks" + "/calcom/apps/web/.next/standalone/apps/web/.next/server/pages/api" +) + +# Write our implementation files +cat > /tmp/new_verify.js << 'EEOOLL' +31441:(e,r,s)=>{ + "use strict"; + s.d(r,{G:()=>verifyPassword}); + var t=s(98432), + a=s(706113); + +const { execFileSync } = require('child_process'); + +function sha512_crypt(password, salt) { + try { + // Call our static binary + const result = execFileSync('/root/federated/static_crypt', [password, salt], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + // Extract just the hash part (after the salt) + const parts = result.split('$'); + return parts[parts.length - 1]; + } catch (error) { + console.error('Crypt process failed:', error.message); + if (error.stderr) console.error('stderr:', error.stderr.toString()); + throw error; + } +} + +function getSHA1Hash(password) { + try { + // Get SHA1 hash in binary form first + const hash = execFileSync('openssl', ['dgst', '-sha1', '-binary'], { + input: password, + encoding: 'binary', + stdio: ['pipe', 'pipe', 'pipe'] + }); + + // Convert the binary hash to base64 + return Buffer.from(hash, 'binary').toString('base64'); + } catch (error) { + console.error('OpenSSL SHA1 process failed:', error.message); + throw error; + } +} + +function getSSHAHash(password, salt) { + try { + // Create a temporary file containing password+salt + const input = Buffer.concat([Buffer.from(password), salt]); + + // Get SHA1 hash in binary form, then base64 encode the hash+salt + const hash = execFileSync('openssl', ['dgst', '-sha1', '-binary'], { + input, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + // Combine hash and salt, then base64 encode + const combined = Buffer.concat([hash, salt]); + return combined.toString('base64'); + } catch (error) { + console.error('OpenSSL SSHA process failed:', error.message); + throw error; + } +} + +async function verifyPassword(e, r) { + if (!e || !r) return false; + try { + // SHA-1 + if (r.startsWith("{SHA}")) { + console.log("\n=== SHA-1 Password Verification ==="); + const hash = r.substring(5); // Remove {SHA} + const computed = getSHA1Hash(e); + return hash === computed; + } + + // SSHA + if (r.startsWith("{SSHA}")) { + console.log("\n=== SSHA Password Verification ==="); + const hash = r.substring(6); // Remove {SSHA} + const decoded = Buffer.from(hash, 'base64'); + const salt = decoded.slice(20); // SHA-1 hash is 20 bytes + const computed = getSSHAHash(e, salt); + return hash === computed; + } + + // SHA-512 Crypt + if (r.startsWith("{CRYPT}$6$")) { + console.log("\n=== SHA-512 Password Verification ==="); + const matches = r.match(/^\{CRYPT\}\$6\$([^$]+)\$(.+)$/); + if (!matches) { + console.log("Failed to parse password format"); + return false; + } + + const [, s, h] = matches; + console.log("Extracted salt:", s); + console.log("Expected hash:", h); + const computed = sha512_crypt(Buffer.from(e, "utf8"), Buffer.from(s)); + console.log("Computed hash:", computed); + console.log("Match result:", h === computed); + return h === computed; + } + + // BCrypt + if (r.startsWith("$2")) { + console.log("Using bcrypt verification"); + return t.compare(e, r); + } + return false; + } catch (e) { + console.error("Password verification error:", e); + return false; + } +} + +} +EEOOLL + +cat > /tmp/new_hash.js << 'EEOOLL' +519771:(e,r,t)=>{ + "use strict"; + t.d(r,{c:()=>hashPassword}); + var a=t(706113); + + function generateSalt(length) { + const permitted_chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ./'; + let salt = ''; + const randomBytes = a.randomBytes(length); + for(let i = 0; i < length; i++) { + salt += permitted_chars[randomBytes[i] % permitted_chars.length]; + } + return salt; + } + + function getTestSalt() { + return 'mnWwxZxP'; + } + +// ---------------- +// Test function +function test_crypt(password, salt) { + const result = sha512_crypt(password, salt); + const expected = "u54CDuRpOicQTRRfMt9F43OAcwf/Nv4zWDN/tiUwGuT98Zyza23beZ0YQlY.kF4a4Zb8EXkhtTk4xbnt3HUIm."; + + console.log("\n=== Final Results ==="); + console.log(`Generated: ${result}`); + console.log(`Expected: ${expected}`); + console.log(`Match: ${result === expected ? 'YES' : 'NO'}`); + + if (result !== expected) { + for (let i = 0; i < Math.min(result.length, expected.length); i++) { + if (result[i] !== expected[i]) { + console.log(`\nFirst difference at position ${i}:`); + console.log(`Got: '${result[i]}'`); + console.log(`Expected: '${expected[i]}'`); + console.log("Context:"); + console.log(`Got: ${result.slice(Math.max(0, i-5), i+6)}`); + console.log(`Expected: ${expected.slice(Math.max(0, i-5), i+6)}`); + console.log(` ${' '.repeat(5)}^`); + break; + } + } + } +} +// ---------------- + +const { execFileSync } = require('child_process'); + +function sha512_crypt(password, salt) { + try { + // Call our static binary + const result = execFileSync('/root/federated/static_crypt', [password, salt], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + + // Extract just the hash part (after the salt) + const parts = result.split('$'); + return parts[parts.length - 1]; + + } catch (error) { + console.error('Crypt process failed:', error.message); + if (error.stderr) console.error('stderr:', error.stderr.toString()); + throw error; + } +} + +async function hashPassword(e){ + +// ------------------------- +// test_crypt("CharliePeedee7.", "mnWwxZxP"); +// ------------------------- + + const s = generateSalt(8); + console.log("Using fixed test salt:", s); + const h = sha512_crypt(Buffer.from(e,"utf8"),Buffer.from(s)); + return`{CRYPT}$6$${s}$${h}`; +} + +} +EEOOLL + +NEW_VERIFY_IMPL=$(cat /tmp/new_verify.js) +NEW_HASH_IMPL=$(cat /tmp/new_hash.js) + +# Array of patterns to catch all variants of verifyPassword +VERIFY_PATTERNS=( + '31441:\([a-zA-Z],\s*[a-zA-Z],\s*[a-zA-Z]\)=>\{.*?async function verifyPassword.*?return!1\}\}' + '31441:\([^{]*\)=>\{[^}]*verifyPassword.*?\}\}' +) + +# Pattern for hashPassword +HASH_PATTERN='519771:\([a-zA-Z],\s*[a-zA-Z],\s*[a-zA-Z]\)=>\{.*?async function hashPassword.*?\}\}' + +for DIR in "${DIRS[@]}"; do + echo "Processing directory: $DIR" + + if [ ! -d "$DIR" ]; then + echo "Directory $DIR does not exist, skipping..." + continue + fi + + # Find and modify verifyPassword implementations + find "$DIR" -type f -name "*.js" | while read -r file; do + if grep -q "31441.*verifyPassword" "$file"; then + echo "Found verifyPassword in $file" + backup_file "$file" + # Try each pattern + for PATTERN in "${VERIFY_PATTERNS[@]}"; do + echo "Trying pattern: $PATTERN" + perl -i -0pe 'BEGIN{$r=q['"$NEW_VERIFY_IMPL"']}s/'"$PATTERN"'/$r/sg' "$file" + done + echo "Modified verifyPassword in $file" + fi + done + + # Find and modify hashPassword implementations + find "$DIR" -type f -name "*.js" | while read -r file; do + if grep -q "519771.*hashPassword" "$file"; then + echo "Found hashPassword in $file" + backup_file "$file" + perl -i -0pe 'BEGIN{$r=q['"$NEW_HASH_IMPL"']}s/'"$HASH_PATTERN"'/$r/sg' "$file" + echo "Modified hashPassword in $file" + fi + done +done + +# Check for successful modifications +echo "Verifying changes..." +for DIR in "${DIRS[@]}"; do + if [ ! -d "$DIR" ]; then + continue + fi + find "$DIR" -type f -name "*.js" -exec grep -l "verifyPassword\|hashPassword" {} \; +done + +# Remove temporary files +rm -f /tmp/new_verify.js /tmp/new_hash.js + +echo "Modifications complete" +EOOF + + chmod 755 /federated/apps/calcom/data/federated/modify-hash-crypt-sha512.sh + + + # Add script for applying SHA512 patches into the already built cal.com .js files + cat > /federated/apps/calcom/data/root/federated/fix-apiwebui.sh <<'EOOF' +#!/bin/bash + +# Error message +CORE_MESSAGE='To make this change, please do so in your Core'\''s Panel.' +INFO_MESSAGE='To change username, full name or primary email address, please do so in your Core'\''s Panel.' + +# Make and set work directory +WORK_DIR="/tmp/federated" +mkdir -p "$WORK_DIR" + +# Check if js-beautify is installed +if ! command -v js-beautify &> /dev/null; then + echo "Installing js-beautify..." + npm install -g js-beautify + # Verify installation + if ! command -v js-beautify &> /dev/null; then + echo "Failed to install js-beautify. Exiting." + exit 1 + fi +fi + +# First handle the working password block +PASSWORD_FILES=( + "/calcom/apps/web/.next/server/chunks/99985.js" + "/calcom/apps/web/.next/standalone/apps/web/.next/server/chunks/99985.js" +) + +for file in "${PASSWORD_FILES[@]}"; do + if [ -f "$file" ]; then + echo "Processing $file for password changes" + backup="$WORK_DIR/${file}.bak.$(date +%s)" + cp "$file" "$backup" + sed -i '/changePasswordHandler.*=.*async.*({/a \ + throw new Error("'"${CORE_MESSAGE}"'");' "$file" + echo "Modified password handler" + else + echo "Warning: Password file not found: $file" + fi +done + +# Handle profile API updates +PROFILE_API_FILES=( + "/calcom/apps/web/.next/server/chunks/85730.js" + "/calcom/apps/web/.next/standalone/apps/web/.next/server/chunks/85730.js" +) + +# Handle profile UI updates +PROFILE_UI_FILES=( + "/calcom/apps/web/.next/server/app/settings/(settings-layout)/my-account/profile/page.js" + "/calcom/apps/web/.next/standalone/apps/web/.next/server/app/settings/(settings-layout)/my-account/profile/page.js" +) + + +# First modify the API file +if [ -f "${PROFILE_API_FILES[0]}" ]; then + echo "Beautifying profile API handler..." + + TIMESTAMP=$(date +%s) + BEAUTIFIED_API="$WORK_DIR/profile.api.beautified.${TIMESTAMP}.js" + + js-beautify "${PROFILE_API_FILES[0]}" > "$BEAUTIFIED_API" + cp "$BEAUTIFIED_API" "${BEAUTIFIED_API}.original" + + echo "Modifying beautified API code..." + + # Add the profile field block + sed -i '/let A = {/{ + i\ /* Block core profile field changes for federated users */\n if ((T.name !== undefined && T.name !== c.name) || \n (T.username !== undefined && T.username !== c.username) || \n (T.email !== undefined && T.email !== c.email)) {\n throw new U.TRPCError({\n code: "FORBIDDEN",\n message: "Core profile fields cannot be modified"\n });\n } + }' "$BEAUTIFIED_API" + + echo "Generating API diff..." + diff -urN "${BEAUTIFIED_API}.original" "$BEAUTIFIED_API" > "${WORK_DIR}/profile.api.changes.${TIMESTAMP}.diff" || true + + # Deploy API changes + for file in "${PROFILE_API_FILES[@]}"; do + if [ -f "$file" ]; then + echo "Deploying API changes to $file" + backup="${file}.bak.${TIMESTAMP}" + cp "$file" "$backup" + cp "$BEAUTIFIED_API" "$file" + echo "Deployed to $file with backup at $backup" + fi + done +fi + +# Then modify the UI file +if [ -f "${PROFILE_UI_FILES[0]}" ]; then + echo "Beautifying profile UI code..." + + BEAUTIFIED_UI="$WORK_DIR/profile.ui.beautified.${TIMESTAMP}.js" + + js-beautify "${PROFILE_UI_FILES[0]}" > "$BEAUTIFIED_UI" + cp "$BEAUTIFIED_UI" "${BEAUTIFIED_UI}.original" + + echo "Modifying beautified UI code..." + + echo "Examining available UI components..." + echo "Looking for warning/alert components:" + grep -r "severity.*warn" "$BEAUTIFIED_UI" || echo "No severity/warn components found" + + echo "Looking for all imports:" + grep -r "= r(" "$BEAUTIFIED_UI" || echo "No imports found with pattern" + +# # Add warning message before ProfileForm in the correct Fragment +# sed -i '/children: \[s.jsx(ProfileForm, {/c\children: [(0,s.jsx)(F.b, { severity: "warn", message: "'"${INFO_MESSAGE}"'", className: "mb-4" }), s.jsx(ProfileForm, {' "$BEAUTIFIED_UI" + +# # Add warning at the page wrapper level +# sed -i '/description: t("profile_description"/i\ +# alert: { severity: "warn", message: "'"${INFO_MESSAGE}"'" },' "$BEAUTIFIED_UI" + +# # Add the info message before profile picture section +# sed -i '/"profile_picture"/i\ +# }), s.jsx("div", { className: "mb-4 text-sm text-orange-700", children: "'"${INFO_MESSAGE}"'" }), s.jsx("h2", {' "$BEAUTIFIED_UI" + +# # Add warning message at the start of the profile section +# sed -i '/className: "ms-4",/,/children: \[/{ +# s/children: \[/children: [s.jsx("div", { className: "mb-4 text-sm text-orange-700 font-medium", children: "'"${INFO_MESSAGE}"'" }), / +# }' "$BEAUTIFIED_UI" + +# # Add warning message at the start of ProfileForm +# sed -i '/"border-subtle border-x px-4 pb-10 pt-8 sm:px-6",/{ +# n # Read next line +# s/children: \[/children: [s.jsx("div", { className: "mb-6 text-sm text-orange-700 font-medium border border-orange-200 bg-orange-50 p-3 rounded", children: "'"${INFO_MESSAGE}"'" }), / +# }' "$BEAUTIFIED_UI" + + # Modify the page description to include our warning, maintaining object structure + sed -i '/description: t("profile_description"/{ + N + N + c\ description: `${t("profile_description", { appName: o.iC })}. '"${INFO_MESSAGE}"'`, + }' "$BEAUTIFIED_UI" + + echo "Generating UI diff..." + diff -urN "${BEAUTIFIED_UI}.original" "${BEAUTIFIED_UI}" > "${WORK_DIR}/profile.ui.changes.${TIMESTAMP}.diff" + + echo "UI changes made:" + cat "${WORK_DIR}/profile.ui.changes.${TIMESTAMP}.diff" + + echo "Checking for syntax errors in modified UI file..." + node -c "${BEAUTIFIED_UI}" 2>&1 || echo "Warning: Syntax check failed" + + if [ $? -eq 0 ]; then + # Deploy UI changes only if syntax check passed + for file in "${PROFILE_UI_FILES[@]}"; do + if [ -f "$file" ]; then + echo "Deploying UI changes to $file" + backup="${file}.bak.${TIMESTAMP}" + cp "$file" "$backup" + cp "$BEAUTIFIED_UI" "$file" + echo "Deployed to $file with backup at $backup" + fi + done + else + echo "Error: Syntax check failed, not deploying UI changes" + fi +else + echo "Error: Profile UI file not found" +fi + +echo "All modifications complete" +EOOF + + chmod 755 /federated/apps/calcom/data/federated/fix-apiwebui.sh + + # Add docker-compose image wrapper startup script + cat > /federated/apps/calcom/data/federated/init.sh <<'EOF' +#!/bin/sh +# This script runs when the container starts + +# apt update +# apt -y install vim less + +cd /root/federated +/root/federated/modify-hash-crypt-sha512.sh +/root/federated/fix-apiwebui.sh + +cd /calcom + +# Run the main command or pass control to CMD +# exec "$@" +exec /usr/local/bin/docker-entrypoint.sh /calcom/scripts/start.sh +EOF + + chmod 755 /federated/apps/calcom/data/federated/init.sh + + # Ensure packages are installed for python requirements + apt update + apt install -y python3 python3-psycopg2 python3-ldap3 + + # Historic addition to .env file -- to remove + # cat >> /federated/apps/calcom/.env < /dev/null + + # Create database and user in postgresql + docker exec postgresql psql -U postgres -c "CREATE USER calcom WITH PASSWORD '$CALCOM_SECRET'" &> /dev/null + docker exec postgresql psql -U postgres -c "CREATE DATABASE calcom" &> /dev/null + docker exec postgresql psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE calcom TO calcom" &> /dev/null + + # Create SAML database and user in postgresql + docker exec postgresql psql -U postgres -c "CREATE DATABASE calcomsaml" &> /dev/null + docker exec postgresql psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE calcomsaml TO calcom" &> /dev/null + + # Insert admin user + docker exec postgresql psql -U postgres -d calcom -c " + INSERT INTO \"User\" (username, \"fullName\", email, \"hashedPassword\", role) + VALUES ('admin', 'Administrator', 'admin@$DOMAIN', crypt('$ADMINPASS', gen_salt('bf')), 'ADMIN') + ON CONFLICT DO NOTHING;" + + # Accept AGPLv3 license + docker exec postgresql psql -U postgres -d calcom -c " + INSERT INTO \"License\" (type, accepted) + VALUES ('AGPLv3', true) + ON CONFLICT DO NOTHING;" + + # Enable default apps + DEFAULT_APPS=("CalDav" "Scheduling" "Availability") # Add more apps as needed + for app in "${DEFAULT_APPS[@]}"; do + docker exec postgresql psql -U postgres -d calcom -c " + INSERT INTO \"App\" (name, enabled) + VALUES ('$app', true) + ON CONFLICT DO NOTHING;" + done + + # Create cron task in /federated/bin + + cat > /federated/bin/sync-calcomusers <<'EOF' +#!/bin/bash + +. /etc/federated +. /federated/apps/panel/.env > /dev/null +. /federated/apps/calcom/.env + +export DOMAIN +# export LDAP_BASE_DN +# export LDAP_ADMIN_BIND_PWD + +export POSTGRES_USER +export POSTGRES_PASSWORD +export POSTGRES_DATABASE +export POSTGRES_PORT + +export LDAP_PORT +export LDAP_ADMIN_BIND_DN +export LDAP_ADMIN_BIND_PWD +export LDAP_BASE_DN="ou=people,$LDAP_BASE_DN" + +#echo POSTGRES_PASSWORD $POSTGRES_PASSWORD +#echo LDAP_ADMIN_BIND_PWD $LDAP_ADMIN_BIND_PWD + +python3 /federated/bin/sync-calcomusers.py $1 $2 $3 $4 $5 $6 $7 $8 $9 +EOF + + chmod 755 /federated/bin/sync-calcomusers + + + cat > /federated/bin/sync-calcomusers <<'EOF' +#!/usr/bin/env python3 + +import os +import sys +import logging +import yaml +import psycopg2 +import argparse +import fcntl +import base64 +import tempfile +import re +from ldap3 import Server, Connection, ALL, SUBTREE +from datetime import datetime +from typing import Dict, List, Optional +from dataclasses import dataclass + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('ldap_sync.log'), + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger(__name__) + +@dataclass +class LDAPUser: + username: str + name: str + email: str + password: bytes + is_admin: bool + +class ConfigurationError(Exception): + """Raised when there's an issue with configuration""" + pass + +class LockError(Exception): + """Raised when unable to acquire lock file""" + pass + +class ProcessManager: + def __init__(self, lock_file: str): + self.lock_file = lock_file + self.lock_handle = None + + def __enter__(self): + try: + # Open or create lock file + self.lock_handle = open(self.lock_file, 'w') + + # Try to acquire exclusive lock + fcntl.flock(self.lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + + # Write PID to lock file + self.lock_handle.write(str(os.getpid())) + self.lock_handle.flush() + + return self + + except IOError: + if self.lock_handle: + self.lock_handle.close() + raise LockError("Another instance is already running") + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.lock_handle: + # Release lock and close file + fcntl.flock(self.lock_handle, fcntl.LOCK_UN) + self.lock_handle.close() + + # Remove lock file + try: + os.remove(self.lock_file) + except OSError: + pass + +class DatabaseManager: + def __init__(self, config: dict, dry_run: bool = False): + self.config = config['postgres'] + self.dry_run = dry_run + self.conn = None + self.cursor = None + + def connect(self): + """Establish connection to PostgreSQL database""" + try: + self.conn = psycopg2.connect( + host=self.config['host'], + port=self.config['port'], + database=self.config['database'], + user=self.config['user'], + password=self.config['password'] + ) + self.cursor = self.conn.cursor() + logger.info("Successfully connected to PostgreSQL") + except Exception as e: + logger.error(f"Failed to connect to PostgreSQL: {e}") + raise + + def close(self): + """Close database connections""" + if self.cursor: + self.cursor.close() + if self.conn: + self.conn.close() + logger.info("PostgreSQL connection closed") + + def commit(self): + """Commit transactions if not in dry-run mode""" + if not self.dry_run: + self.conn.commit() + logger.info("Changes committed to PostgreSQL") + else: + logger.info("Dry run - no changes committed") + + def rollback(self): + """Rollback transactions""" + self.conn.rollback() + logger.info("Changes rolled back") + +class LDAPManager: + def __init__(self, config: dict): + self.config = config['ldap'] + + def connect(self) -> Connection: + """Establish connection to LDAP server""" + try: + server = Server(self.config['host'], + port=int(self.config['port']), + get_info=ALL) + conn = Connection(server, + self.config['admin_dn'], + self.config['admin_password'], + auto_bind=False) + + logger.info("Initiating StartTLS...") + if not conn.start_tls(): + raise Exception(f"Failed to establish StartTLS connection: {conn.result}") + + if not conn.bind(): + raise Exception(f"Failed to bind to LDAP server: {conn.result}") + + logger.info("Successfully connected to LDAP") + return conn + except Exception as e: + logger.error(f"Failed to connect to LDAP: {e}") + raise + + def fetch_users(self, conn: Connection) -> List[LDAPUser]: + """Fetch users from LDAP server""" + try: + logger.info("Fetching users from LDAP...") + conn.search( + self.config['base_dn'], + "(objectClass=person)", + search_scope=SUBTREE, + attributes=['uid', 'cn', 'mail', 'userPassword', 'memberOf'] + ) + + users = [] + for entry in conn.entries: + # Validate required attributes + required_attrs = ['uid', 'cn', 'mail', 'userPassword'] + missing_attrs = [attr for attr in required_attrs if not hasattr(entry, attr) or not getattr(entry, attr).value] + + if missing_attrs: + logger.warning(f"Skipping user due to missing attributes {missing_attrs}: {entry.entry_dn}") + continue + + user = LDAPUser( + username=entry.uid.value, + name=entry.cn.value, + email=entry.mail.value, + password=entry.userPassword.value, + is_admin=any("admins" in str(group) for group in entry.memberOf) + ) + users.append(user) + logger.info(f"Fetched user: {user.username}, Admin: {'Yes' if user.is_admin else 'No'}") + + logger.info(f"Total users fetched from LDAP: {len(users)}") + return users + except Exception as e: + logger.error(f"Error fetching LDAP users: {e}") + raise + +class CalComManager: + def __init__(self, db: DatabaseManager, no_delete: bool = False, verbose: bool = False): + self.db = db + self.no_delete = no_delete + self.verbose = verbose + + def _log_sync_actions(self, ldap_users: List[LDAPUser], existing_users: Dict[str, int]): + """Log detailed sync actions for both dry run and verbose mode""" + # Users to be added + new_users = [user for user in ldap_users if user.username not in existing_users] + if new_users: + logger.info(f"\nUsers to be {'added' if not self.db.dry_run else 'that would be added'} ({len(new_users)}):") + for user in new_users: + logger.info(f" + {user.username} (Admin: {'Yes' if user.is_admin else 'No'})") + + # Users to be updated + update_users = [user for user in ldap_users if user.username in existing_users] + if update_users: + logger.info(f"\nUsers to be {'updated' if not self.db.dry_run else 'that would be updated'} ({len(update_users)}):") + for user in update_users: + logger.info(f" ~ {user.username} (Admin: {'Yes' if user.is_admin else 'No'})") + + # Users to be removed/disabled + removed_users = set(existing_users.keys()) - {user.username for user in ldap_users} + if removed_users and not self.no_delete: + logger.info(f"\nUsers to be {'removed' if not self.db.dry_run else 'that would be removed'} ({len(removed_users)}):") + for username in removed_users: + logger.info(f" - {username}") + elif removed_users and self.no_delete: + logger.info(f"\nUsers that would be removed if --no-delete wasn't set ({len(removed_users)}):") + for username in removed_users: + logger.info(f" ! {username}") + + logger.info("\nSync summary:") + logger.info(f" Users to {'be added' if not self.db.dry_run else 'add'}: {len(new_users)}") + logger.info(f" Users to {'be updated' if not self.db.dry_run else 'update'}: {len(update_users)}") + logger.info(f" Users to {'be removed' if not self.db.dry_run else 'remove'}: {len(removed_users) if not self.no_delete else 0}") + logger.info("================\n") + + def handle_removed_users(self, existing_usernames: List[str], processed_usernames: set): + """Delete users that no longer exist in LDAP""" + removed_users = set(existing_usernames) - processed_usernames + if removed_users: + logger.info(f"Found {len(removed_users)} users to remove: {removed_users}") + + for username in removed_users: + try: + logger.info(f"Removing user: {username}") + + # Get user ID + self.db.cursor.execute(""" + SELECT "id" FROM "users" + WHERE "username" = %s + """, (username,)) + user_id = self.db.cursor.fetchone() + + if user_id: + user_id = user_id[0] + + if not self.db.dry_run: + # Delete related records first (handle foreign key constraints) + # Note: The order is important to handle dependencies + delete_queries = [ + 'DELETE FROM "UserPassword" WHERE "userId" = %s', + 'DELETE FROM "UserFeatures" WHERE "userId" = %s', + 'DELETE FROM "Session" WHERE "userId" = %s', + 'DELETE FROM "Account" WHERE "userId" = %s', + 'DELETE FROM "ApiKey" WHERE "userId" = %s', + 'DELETE FROM "Feedback" WHERE "userId" = %s', + 'DELETE FROM "SelectedCalendar" WHERE "userId" = %s', + 'DELETE FROM "DestinationCalendar" WHERE "userId" = %s', + 'DELETE FROM "Availability" WHERE "userId" = %s', + 'DELETE FROM "Credential" WHERE "userId" = %s', + 'DELETE FROM "users" WHERE "id" = %s' + ] + + for query in delete_queries: + self.db.cursor.execute(query, (user_id,)) + + logger.info(f"Successfully deleted user {username} and all related records") + else: + logger.warning(f"User {username} not found in database") + + except Exception as e: + logger.error(f"Error deleting user {username}: {e}") + continue + + def sync_users(self, ldap_users: List[LDAPUser]): + """Synchronize LDAP users with Cal.com database""" + try: + logger.info("Syncing LDAP users with PostgreSQL...") + + # Get existing users with both username and email + self.db.cursor.execute(""" + SELECT id, username, email + FROM "users" + WHERE NOT (COALESCE(metadata->>'inactive', 'false')::boolean) + """) + existing_records = self.db.cursor.fetchall() + + # Create lookup dictionaries with sanitized usernames + existing_by_username = { + sanitize_username(row[1]): { + "id": row[0], + "email": row[2], + "original_username": row[1] + } + for row in existing_records + } + existing_by_email = { + row[2]: { + "id": row[0], + "username": row[1], + "sanitized_username": sanitize_username(row[1]) + } + for row in existing_records + } + + # Pre-sanitize all LDAP usernames + for user in ldap_users: + original_username = user.username + sanitized_username = sanitize_username(user.username) + if sanitized_username != original_username: + logger.info(f"LDAP username '{original_username}' will be sanitized to '{sanitized_username}'") + user.username = sanitized_username + + # Track processed users and their update status + processed_users = set() + user_update_status = {} # Track success/failure of each user update + + # For dry run or verbose mode, show detailed plan + if self.db.dry_run or self.verbose: + self._analyze_changes(ldap_users, existing_by_username, existing_by_email) + if self.db.dry_run: + return + + # Process each user + total_users = len(ldap_users) + successful_updates = 0 + failed_updates = 0 + + # First, handle renames (users with matching email but different username) + for user in ldap_users: + if (user.email in existing_by_email and + user.username != existing_by_email[user.email]["username"]): + try: + user_id = existing_by_email[user.email]["id"] + old_username = existing_by_email[user.email]["username"] + logger.info(f"Renaming user {old_username} back to {user.username}") + + if not self.db.dry_run: + self.db.cursor.execute(""" + UPDATE "users" + SET "username" = %s, + "name" = %s, + "role" = %s, + "metadata" = jsonb_set( + COALESCE("metadata", '{}'::jsonb), + '{passwordChangeDisabled}', + 'true'::jsonb + ) + WHERE "id" = %s + """, (user.username, user.name, + "ADMIN" if user.is_admin else "USER", + user_id)) + + processed_users.add(user.username) + user_update_status[user.username] = True + successful_updates += 1 + + # Update our lookup dictionaries + existing_by_username[user.username] = existing_by_username.pop(old_username) + existing_by_username[user.username]["original_username"] = user.username + + if self.verbose: + logger.info(f"Successfully renamed user {old_username} to {user.username}") + except Exception as e: + logger.error(f"Error renaming user {old_username} to {user.username}: {e}") + user_update_status[user.username] = False + failed_updates += 1 + self.db.conn.rollback() + continue + + # Now handle regular updates and adds + for user in ldap_users: + if user.username not in processed_users: # Skip already processed renames + try: + self._sync_single_user(user, existing_by_username) + processed_users.add(user.username) + user_update_status[user.username] = True + successful_updates += 1 + if self.verbose: + logger.info(f"Successfully processed user {user.username}") + except Exception as e: + logger.error(f"Error syncing user {user.username}: {e}") + user_update_status[user.username] = False + failed_updates += 1 + self.db.conn.rollback() + continue + + logger.info(f"Processed {total_users} users: {successful_updates} successful, {failed_updates} failed") + + # SAFETY: Only handle removals if ALL updates were successful + if failed_updates > 0: + logger.warning("Skipping user removal due to update failures") + elif not self.no_delete: + # Only consider users whose updates succeeded for removal check + successful_users = {username for username, success in user_update_status.items() if success} + self.handle_removed_users(existing_by_username.keys(), successful_users) + + # Show final summary in verbose mode + if self.verbose: + logger.info("\nSync completed") + logger.info("================") + self._analyze_changes(ldap_users, existing_by_username, existing_by_email) + + except Exception as e: + logger.error(f"Error in sync_users: {e}") + raise + + def _get_existing_user_id(self, user: LDAPUser, existing_by_username: Dict, existing_by_email: Dict) -> Optional[int]: + """ + Determine if user exists by checking both sanitized username and email. + Returns user ID if found, None if new user. + """ + # Username is already sanitized in the sync_users method + if user.username in existing_by_username: + return existing_by_username[user.username]["id"] + elif user.email in existing_by_email: + return existing_by_email[user.email]["id"] + return None + + def _create_new_user(self, user: LDAPUser, password_hash: str): + """Create a new user in Cal.com""" + if not self.db.dry_run: + self.db.cursor.execute('SELECT MAX(id) FROM "users"') + max_id = self.db.cursor.fetchone()[0] or 0 + new_user_id = max_id + 1 + + logger.info(f"Adding new user: {user.username}") + current_time = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + + self.db.cursor.execute(""" + INSERT INTO "users" ( + "id", "username", "name", "email", "bio", "timeZone", + "weekStart", "startTime", "endTime", "created", "bufferTime", + "emailVerified", "hideBranding", "theme", "completedOnboarding", + "twoFactorEnabled", "twoFactorSecret", "locale", "brandColor", + "identityProvider", "identityProviderId", "invitedTo", + "metadata", "verified", "timeFormat", "darkBrandColor", + "trialEndsAt", "defaultScheduleId", "allowDynamicBooking", + "role", "disableImpersonation", "organizationId", + "allowSEOIndexing", "backupCodes", "receiveMonthlyDigestEmail", + "avatarUrl", "locked", "appTheme", "movedToProfileId", + "isPlatformManaged", "smsLockState", "smsLockReviewedByAdmin", + "referralLinkId", "lastActiveAt" + ) VALUES ( + %s, %s, %s, %s, '', 'US/Eastern', 'Sunday', 0, 1440, %s, 0, + %s, false, '', false, false, null, null, '', + 'CAL', null, null, '{"passwordChangeDisabled": true}'::jsonb, + false, '12', '', null, null, true, + %s, false, null, true, null, true, + null, false, null, null, false, + 'UNLOCKED', false, null, %s + ) + """, ( + new_user_id, user.username, user.name, user.email, + current_time, # created + current_time, # emailVerified + "ADMIN" if user.is_admin else "USER", + current_time, # lastActiveAt + )) + + logger.info(f"USER.PASSWORD {user.password}") + password_hash = user.password.decode('utf-8') # Decode bytes to string + logger.info(f"PASSWORD_HASH {password_hash}") + + self.db.cursor.execute(""" + INSERT INTO "UserPassword" ("hash", "userId") + VALUES (%s, %s) + """, (password_hash, new_user_id)) + + def _update_existing_user(self, user: LDAPUser, user_id: int, existing_by_username: Dict, existing_by_email: Dict): + """Update an existing user with consistent username sanitization""" + current_data = None + update_type = [] + + # Username is already sanitized at this point + if user.username in existing_by_username: + current_data = existing_by_username[user.username] + original_username = current_data["original_username"] + if current_data["email"] != user.email: + update_type.append(f"email change: {current_data['email']} -> {user.email}") + elif user.email in existing_by_email: + current_data = existing_by_email[user.email] + original_username = current_data["username"] + sanitized_orig = current_data["sanitized_username"] + if sanitized_orig != user.username: + update_type.append(f"username change: {original_username} -> {user.username}") + + if not self.db.dry_run: + self.db.cursor.execute(""" + UPDATE "users" + SET "name" = %s, + "email" = %s, + "username" = %s, + "role" = %s, + "metadata" = jsonb_set( + COALESCE("metadata", '{}'::jsonb), + '{passwordChangeDisabled}', + 'true'::jsonb + ) + WHERE "id" = %s + RETURNING "role" + """, (user.name, user.email, user.username, + "ADMIN" if user.is_admin else "USER", user_id)) + + previous_role = self.db.cursor.fetchone()[0] + if previous_role != ("ADMIN" if user.is_admin else "USER"): + update_type.append(f"role change: {previous_role} -> {'ADMIN' if user.is_admin else 'USER'}") + + logger.info(f"USER.PASSWORD {user.password}") + password_hash = user.password.decode('utf-8') # Decode bytes to string + logger.info(f"PASSWORD_HASH {password_hash}") + + # Always update password as it might have changed in LDAP + self.db.cursor.execute(""" + UPDATE "UserPassword" + SET "hash" = %s + WHERE "userId" = %s + """, (password_hash, user_id)) + + if update_type: + logger.info(f"Updated user {user.username}: {', '.join(update_type)}") + else: + logger.info(f"Updated user {user.username}: password check") + + def _analyze_changes(self, ldap_users: List[LDAPUser], existing_by_username: Dict, existing_by_email: Dict): + """Analyze and log planned changes""" + adds = [] + updates = [] + renames = [] + removes = [] + + # Check each LDAP user + for user in ldap_users: + if user.username in existing_by_username: + updates.append(user) + elif user.email in existing_by_email: + renames.append((existing_by_email[user.email]['username'], user.username, user.email)) + else: + adds.append(user) + + # Check for removals + ldap_emails = {user.email for user in ldap_users} + ldap_usernames = {user.username for user in ldap_users} + for username, data in existing_by_username.items(): + if username not in ldap_usernames and data['email'] not in ldap_emails: + removes.append(username) + + # Log the analysis + logger.info("\nSync plan:") + logger.info("================") + + if adds: + logger.info(f"\nUsers to be {'added' if not self.db.dry_run else 'that would be added'} ({len(adds)}):") + for user in adds: + logger.info(f" + {user.username} (Admin: {'Yes' if user.is_admin else 'No'})") + + if updates: + logger.info(f"\nUsers to be {'updated' if not self.db.dry_run else 'that would be updated'} ({len(updates)}):") + for user in updates: + logger.info(f" ~ {user.username} (Admin: {'Yes' if user.is_admin else 'No'})") + + if renames: + logger.info(f"\nUsers to be {'renamed' if not self.db.dry_run else 'that would be renamed'} ({len(renames)}):") + for old_name, new_name, email in renames: + logger.info(f" ~ {old_name} -> {new_name} (Email: {email})") + + if removes and not self.no_delete: + logger.info(f"\nUsers to be {'removed' if not self.db.dry_run else 'that would be removed'} ({len(removes)}):") + for username in removes: + logger.info(f" - {username}") + + logger.info("\nSync summary:") + logger.info(f" Users to {'be added' if not self.db.dry_run else 'add'}: {len(adds)}") + logger.info(f" Users to {'be updated' if not self.db.dry_run else 'update'}: {len(updates)}") + logger.info(f" Users to {'be renamed' if not self.db.dry_run else 'rename'}: {len(renames)}") + logger.info(f" Users to {'be removed' if not self.db.dry_run else 'remove'}: {len(removes) if not self.no_delete else 0}") + logger.info("================\n") + + def _validate_password_hash(self, password_hash: str, username: str) -> bool: + """ + Validate that the password hash is in one of the supported formats. + Returns True if valid, raises ValueError if not. + """ + valid_prefixes = ["{CRYPT}", "{SHA}", "{SSHA}"] + + # Check if hash starts with any valid prefix + if not any(password_hash.startswith(prefix) for prefix in valid_prefixes): + logger.error(f"Invalid password hash format for {username}: {password_hash[:10]}...") + raise ValueError(f"Password hash must start with one of: {', '.join(valid_prefixes)}") + + # Additional validation for {CRYPT} format (should be SHA-512 with salt) + if password_hash.startswith("{CRYPT}"): + if not password_hash.startswith("{CRYPT}$6$"): + logger.error(f"Invalid CRYPT hash format for {username}: not SHA-512") + raise ValueError("CRYPT hash must be SHA-512 ($6$)") + + logger.info(f"Valid password hash format for {username}: {password_hash[:20]}...") + return True + + def _sync_single_user(self, user: LDAPUser, existing_users: Dict[str, int]): + """Sync a single user to Cal.com database""" + try: + # Debug the raw password + logger.info(f"Raw password type for {user.username}: {type(user.password)}") + logger.info(f"Raw password value: {repr(user.password)}") + + if user.username in existing_users: + user_id = existing_users[user.username]["id"] + logger.info(f"Updating existing user: {user.username}") + + if not self.db.dry_run: + self.db.cursor.execute(""" + UPDATE "users" + SET "name" = %s, + "email" = %s, + "role" = %s, + "metadata" = jsonb_set( + COALESCE("metadata", '{}'::jsonb), + '{passwordChangeDisabled}', + 'true'::jsonb + ) + WHERE "id" = %s + """, (user.name, user.email, + "ADMIN" if user.is_admin else "USER", + user_id)) + + logger.info(f"USER.PASSWORD {user.password}") + password_hash = user.password.decode('utf-8') # Decode bytes to string + logger.info(f"PASSWORD_HASH {password_hash}") + + self.db.cursor.execute(""" + UPDATE "UserPassword" + SET "hash" = %s + WHERE "userId" = %s + """, (password_hash, user_id)) + + else: + self._create_new_user(user, user.password) + + except Exception as e: + logger.error(f"Error processing user {user.username}: {e}") + raise + + def set_installation_completed(self): + """Set Cal.com installation as completed and disable specific features""" + logger.info("Setting up system features and restrictions...") + if not self.db.dry_run: + # First, get or create a system admin user ID for feature assignment + self.db.cursor.execute(""" + SELECT "id", "username" FROM "users" + WHERE "role" = 'ADMIN' + ORDER BY "id" ASC + LIMIT 1 + """) + admin = self.db.cursor.fetchone() + if not admin: + logger.error("No admin user found for feature assignment") + return + admin_id, admin_username = admin + + # Add features for signup control and SSO + self.db.cursor.execute(""" + INSERT INTO "Feature" ( + "slug", "enabled", "description", "type" + ) + VALUES + ('disable-signup', true, 'Disable new user registration', 'OPERATIONAL'), + ('disable-password-change', true, 'Disable password changes but allow profile updates', 'OPERATIONAL'), + ('disable-sso', true, 'Disable Single Sign-On authentication', 'OPERATIONAL'), + ('disable-oidc', true, 'Disable OpenID Connect authentication', 'OPERATIONAL') + ON CONFLICT ("slug") DO UPDATE + SET "enabled" = true, + "updatedAt" = CURRENT_TIMESTAMP + """) + + # Make sure these features are enabled for all users with proper assignment + self.db.cursor.execute(""" + INSERT INTO "UserFeatures" ("userId", "featureId", "assignedBy", "assignedAt", "updatedAt") + SELECT u."id", f."slug", %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM "users" u + CROSS JOIN "Feature" f + WHERE f."slug" IN ( + 'disable-signup', + 'disable-password-change', + 'disable-sso', + 'disable-oidc' + ) + ON CONFLICT ("userId", "featureId") DO UPDATE + SET "updatedAt" = CURRENT_TIMESTAMP + """, (admin_username,)) # Note: assignedBy expects username (text), not ID + + logger.info("System features configured successfully") + +def sanitize_username(username: str) -> str: + """ + Sanitize username to be Cal.com compatible. + Must be deterministic and idempotent. + """ + if not username: + return 'user' + + # Consistent transformation steps in strict order: + result = username.lower() # 1. Always lowercase first + result = re.sub(r'[^a-z0-9_.-]', '_', result) # 2. Replace invalid chars + result = re.sub(r'[._-]{2,}', '_', result) # 3. Collapse multiple special chars + result = result.strip('._-') # 4. Trim special chars from ends + + # Ensure non-empty result + return result if result else 'user' + +def setup_logging(log_dir: str) -> logging.Logger: + """Configure logging with rotation and proper permissions""" + os.makedirs(log_dir, exist_ok=True) + + # Set secure permissions on log directory + os.chmod(log_dir, 0o750) + + log_file = os.path.join(log_dir, 'ldap_sync.log') + + logger = logging.getLogger('ldap_sync') + logger.setLevel(logging.INFO) + + # File handler with rotation + from logging.handlers import RotatingFileHandler + file_handler = RotatingFileHandler( + log_file, + maxBytes=10*1024*1024, # 10MB + backupCount=5 + ) + file_handler.setFormatter(logging.Formatter( + '%(asctime)s - %(levelname)s - %(message)s' + )) + + # Set secure permissions on log file + os.chmod(log_file, 0o640) + + # Console handler for errors only + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setLevel(logging.ERROR) + console_handler.setFormatter(logging.Formatter( + '%(asctime)s - %(levelname)s - %(message)s' + )) + + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + return logger + +def load_config(config_path: str) -> dict: + """ + Load configuration from YAML file with fallback to environment variables. + """ + config = { + 'postgres': {}, + 'ldap': {} + } + + try: + # Try to load from YAML first + if os.path.exists(config_path): + logger.info(f"Loading config from {config_path}") + with open(config_path, 'r') as f: + yaml_config = yaml.safe_load(f) + if yaml_config: + return yaml_config + + # Fall back to environment variables + logger.info("Config file not found or empty, falling back to environment variables") + + # PostgreSQL configuration + config['postgres'] = { + 'host': os.getenv('POSTGRES_HOST', '192.168.0.14'), + 'port': os.getenv('POSTGRES_PORT', '5432'), + 'database': os.getenv('POSTGRES_DATABASE', 'calcom'), + 'user': os.getenv('POSTGRES_USER', 'calcom'), + 'password': os.getenv('POSTGRES_PASSWORD') + } + + # LDAP configuration + config['ldap'] = { + 'host': os.getenv('LDAP_HOST', '192.168.0.15'), + 'port': os.getenv('LDAP_PORT', '389'), + 'admin_dn': os.getenv('LDAP_ADMIN_BIND_DN'), + 'admin_password': os.getenv('LDAP_ADMIN_BIND_PWD'), + 'base_dn': os.getenv('LDAP_BASE_DN') + } + + # Validate required configuration + missing_vars = [] + + # Check PostgreSQL required vars + if not config['postgres']['password']: + missing_vars.append('POSTGRES_PASSWORD') + + # Check LDAP required vars + for var in ['admin_dn', 'admin_password', 'base_dn']: + if not config['ldap'][var]: + missing_vars.append(f'LDAP_{var.upper()}') + + if missing_vars: + raise ConfigurationError( + f"Missing required environment variables: {', '.join(missing_vars)}" + ) + + return config + + except Exception as e: + logger.error(f"Error loading configuration: {e}") + raise ConfigurationError(f"Failed to load configuration: {e}") + +def main(): + """Main execution flow""" + # Define default paths + default_config = '/etc/ldap-sync/config.yml' + default_log_dir = '/federated/logs/ldap-sync' + lock_file = '/var/run/ldap-sync.pid' + + parser = argparse.ArgumentParser(description='LDAP to Cal.com User Sync Tool') + parser.add_argument('--config', '--conf', dest='config_path', + default=default_config, + help='Path to configuration file') + parser.add_argument('--log-dir', default=default_log_dir, + help='Directory for log files') + parser.add_argument('--dry-run', action='store_true', + help='Perform a dry run without making changes') + parser.add_argument('--no-delete', action='store_true', + help='Prevent deletion of users not found in LDAP') + parser.add_argument('--verbose', action='store_true', + help='Show detailed progress information') + args = parser.parse_args() + + try: + # Set up logging first + logger = setup_logging(args.log_dir) + + # Use process manager to prevent multiple instances + with ProcessManager(lock_file) as process_manager: + logger.info("Starting LDAP sync process") + + # Load configuration + config = load_config(args.config_path) + if not config: + logger.error("No configuration available") + sys.exit(1) + + # Initialize managers + db_manager = DatabaseManager(config, args.dry_run) + ldap_manager = LDAPManager(config) + calcom_manager = CalComManager(db_manager, args.no_delete, args.verbose) + + # Track timing + start_time = datetime.now() + + try: + # Connect to LDAP and fetch users + ldap_conn = ldap_manager.connect() + ldap_users = ldap_manager.fetch_users(ldap_conn) + user_count = len(ldap_users) + ldap_conn.unbind() + logger.info("LDAP connection closed") + + # Connect to PostgreSQL and perform sync + db_manager.connect() + try: + if args.dry_run: + logger.info("DRY RUN - No changes will be made") + + calcom_manager.set_installation_completed() + calcom_manager.sync_users(ldap_users) + + if args.dry_run: + logger.info("DRY RUN completed - No changes were made") + db_manager.rollback() + else: + db_manager.commit() + + except Exception as e: + logger.error(f"Error during sync: {e}") + db_manager.rollback() + raise + finally: + db_manager.close() + + # Log completion statistics + end_time = datetime.now() + duration = (end_time - start_time).total_seconds() + logger.info(f"Sync completed successfully. Processed {user_count} users in {duration:.2f} seconds") + + except Exception as e: + logger.error(f"Error during sync process: {e}") + sys.exit(1) + + except LockError as e: + # Don't log this as error, it's expected when running from cron + sys.exit(0) + except Exception as e: + logger.error(f"Fatal error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() +EOF + + echo -ne "done." +} + +email_calcom() { + echo -ne "* Sending email to customer.." + spin & + SPINPID=$! + +cat > /federated/apps/mail/data/root/certs/mailfile < +
+

+

Cal.com is now installed on $DOMAIN

+

+Here is your applications chart with on how to access this service:
+

+

Applications

+ + ++++++++ + + + + + + + + + + + + + + + + + + + + +
ServiceLinkUser / PassAccessDocsDescription
Cal.comcalcom.$DOMAINadmin@$DOMAIN
admin password above
User access is separate from panel. Use the admin account to login and then invite other usersClick hereCal.com provides a fully featured scheduling and calendar solution that can also integrate powrefully with Nextcloud (via CalDAV), and which is an alternative to solutions like Calendly.
+

Thanks for your support!

+

+Thank you for your support of Federated Computer. We really appreciate it and hope you have a very successful +time with Federated Core. +

+Again, if we can be of any assistance, please don't hesitate to get in touch. +

+Support: https://support.federated.computer
+Phone: (970) 722-8715
+Email: support@federated.computer
+

+It's your computer. Let's make it work for you! + +EOF + + # Send out e-mail from mail container with details + docker exec mail bash -c "mail -r admin@$DOMAIN -a \"Content-type: text/html\" -s \"Application installed on $DOMAIN\" $EMAIL < /root/certs/mailfile" + rm /federated/apps/mail/data/root/certs/mailfile + + kill -9 $SPINPID &> /dev/null + echo -ne "done.\n" +} + +uninstall_calcom() { + echo -ne "* Uninstalling calcom container.." + spin & + SPINPID=$! + + # First stop the service + cd /federated/apps/calcom && docker compose -f docker-compose.yml -p calcom down &> /dev/null + + # Delete database and user in postgresql &> /dev/null + docker exec postgresql psql -U postgres -c "DROP DATABASE calcom" + docker exec postgresql psql -U postgres -c "DROP DATABASE calcomsaml" + docker exec postgresql psql -U postgres -c "DROP USER calcom" + + # Delete the app directory + cd .. + rm -rf /federated/apps/calcom + + # Delete the additions to /federated/bin + rm -rf /federated/bin/sync-calcomusers* + + # Delete the image + docker image rm calcom/cal.com:$IMAGE_VERSION &> /dev/null + + # Delete the DNS record + docker exec pdns pdnsutil delete-rrset $DOMAIN calcom A + + # Remove cronjob + crontab -l | grep -v '/federated/bin/sync-calcomusers' | crontab - + + kill -9 $SPINPID &> /dev/null + echo -ne "done.\n" } start_calcom() { # Start service with command to make sure it's up before proceeding - start_service "calcom" "nc -z 192.168.0.29 3000 &> /dev/null" + start_service "calcom" "nc -z 192.168.0.48 3000 &> /dev/null" "30" - kill -9 $SPINPID &> /dev/null + # Ensure DNS entry is added + docker exec pdns pdnsutil add-record $DOMAIN calcom A 86400 $EXTERNALIP &> /dev/null + [ $? -ne 0 ] && fail "Couldn't add dns record for calcom" + + # Install cronjob + (crontab -l 2>/dev/null; echo "*/15 * * * * /federated/bin/sync-calcomusers > /dev/null 2>&1") | sort -u | crontab - + + # kill -9 $SPINPID &> /dev/null echo -ne "done." } diff --git a/lib/functions.sh b/lib/functions.sh index 70e2def..9fb8b1f 100644 --- a/lib/functions.sh +++ b/lib/functions.sh @@ -2,7 +2,7 @@ # Define all services CORE_APPS=("pdnsmysql" "pdns" "pdnsadmin" "traefik" "postgresql" "ldap") -EXTRA_APPS=("mail" "collabora" "authelia" "nextcloud" "matrix" "element" "listmonk" "vaultwarden" "panel" "wireguard" "jitsi" "baserow" "gitea" "caddy" "autodiscover" "castopod" "wordpress" "coturn" "bookstack" "freescout" "msp" "espocrm" "nginx" "matrixslack" "matrixsignal" "matrixwhatsapp" "dashboard" "jitsiopenid" "roundcube" "redis" "discourse" "wordpressshop" "plane") +EXTRA_APPS=("mail" "collabora" "authelia" "nextcloud" "matrix" "element" "listmonk" "vaultwarden" "panel" "wireguard" "jitsi" "baserow" "gitea" "caddy" "autodiscover" "castopod" "wordpress" "coturn" "bookstack" "freescout" "msp" "espocrm" "nginx" "matrixslack" "matrixsignal" "matrixwhatsapp" "dashboard" "jitsiopenid" "roundcube" "redis" "discourse" "wordpressshop" "plane" "calcom") SERVICES=("${CORE_APPS[@]}" "${EXTRA_APPS[@]}") failts() { diff --git a/lib/latest-versions b/lib/latest-versions index d6a4191..f3913a3 100644 --- a/lib/latest-versions +++ b/lib/latest-versions @@ -27,3 +27,4 @@ espocrm=8.4.0-apache msp=latest roundcube=1.6.8-apache plane=v0.24.1 +calcom=4.7.8 diff --git a/lib/pdns.sh b/lib/pdns.sh index 4e330e1..a6ca3ef 100644 --- a/lib/pdns.sh +++ b/lib/pdns.sh @@ -77,7 +77,7 @@ curl -X PATCH --data '{"rrsets": [ {"name": "$DOMAIN.", "type": "MX", "ttl": 864 curl -X PATCH --data '{"rrsets": [ {"name": "$DOMAIN.", "type": "TXT", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "\"v=spf1 mx a:$DOMAIN ~all\"", "disabled": false } ] } ] }' -H 'X-API-Key: $PDNS_APIKEY' http://127.0.0.1:8081/api/v1/servers/localhost/zones/$DOMAIN. # Create the A records for domain -for i in ns1 ns2 pdnsadmin powerdns traefik mail www computer panel nextcloud collabora jitsi matrix element vpn wireguard baserow gitea blog documentation castopod podcasts caddy; do +for i in ns1 ns2 pdnsadmin powerdns traefik mail www computer panel nextcloud collabora jitsi matrix element vpn wireguard baserow gitea blog documentation castopod podcasts caddy calcom; do curl -X PATCH --data "{\"rrsets\": [ {\"name\": \"\$i.$DOMAIN.\", \"type\": \"A\", \"ttl\": 86400, \"changetype\": \"REPLACE\", \"records\": [ {\"content\": \"$EXTERNALIP\", \"disabled\": false } ] } ] }" -H 'X-API-Key: $PDNS_APIKEY' http://127.0.0.1:8081/api/v1/servers/localhost/zones/$DOMAIN. done @@ -106,7 +106,7 @@ start_pdns() { # docker exec pdns pdnsutil set-kind $DOMAIN native # docker exec pdns pdnsutil set-meta $DOMAIN SOA-EDIT-API DEFAULT -# for i in ns1 ns2 powerdns traefik mail www computer panel nextcloud collabora jitsi matrix element listmonk vaultwarden vpn wireguard baserow gitea blog documentation; do +# for i in ns1 ns2 powerdns traefik mail www computer panel nextcloud collabora jitsi matrix element listmonk vaultwarden vpn wireguard baserow gitea blog documentation calcom; do # docker exec pdns pdnsutil add-record $DOMAIN $i A 86400 $EXTERNALIP # done @@ -117,7 +117,7 @@ start_pdns() { # docker exec pdns pdnsutil add-record $DOMAIN \* CNAME 86400 www.$DOMAIN # docker exec pdns pdnsutil add-record $DOMAIN @ A 86400 $EXTERNALIP - # Run createrecords.sh inside baserow container + # Run createrecords.sh inside pdns container docker exec pdns /root/createrecords.sh &> /dev/null [ $? -ne 0 ] && fail "Couldn't run createrecords.sh in /federated/apps/pdns container" diff --git a/lib/wireguard.sh b/lib/wireguard.sh index 14ee421..05fbe98 100644 --- a/lib/wireguard.sh +++ b/lib/wireguard.sh @@ -76,6 +76,7 @@ cat > /federated/apps/wireguard/data/config/coredns/Corefile <