1877 lines
73 KiB
Bash
1877 lines
73 KiB
Bash
#!/bin/bash
|
|
#
|
|
# 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=$!
|
|
|
|
if [ ! -d "/federated/apps/calcom" ]; then
|
|
mkdir -p /federated/apps/calcom
|
|
fi
|
|
|
|
CALCOM_SECRET=$(create_password)
|
|
|
|
# DOMAIN_ARRAY=(${DOMAIN//./ })
|
|
# DOMAIN_FIRST=${DOMAIN_ARRAY[0]}
|
|
# DOMAIN_LAST=${DOMAIN_ARRAY[1]}
|
|
|
|
if [ "$(uname -m)" = "aarch64" ]; then
|
|
CONTAINER=federatedcomputer
|
|
else
|
|
CONTAINER=calcom
|
|
fi
|
|
|
|
cat > /federated/apps/calcom/docker-compose.yml <<EOF
|
|
services:
|
|
calcom:
|
|
image: calcom/cal.com:\${IMAGE_VERSION}
|
|
container_name: calcom
|
|
# hostname: calcom.$DOMAIN
|
|
restart: always
|
|
# ports:
|
|
# - "3000:3000" # Map external port 3000 to internal port 3000 for broken code workaround
|
|
networks:
|
|
core:
|
|
ipv4_address: 192.168.0.48
|
|
volumes:
|
|
- ./data/root/federated:/root/federated
|
|
entrypoint: /root/federated/init.sh
|
|
env_file:
|
|
- ./.env
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.http.routers.calcom.rule=Host(\`calcom.$DOMAIN\`)"
|
|
- "traefik.http.routers.calcom.entrypoints=websecure"
|
|
- "traefik.http.routers.calcom.tls.certresolver=letsencrypt"
|
|
- "traefik.http.services.calcom.loadbalancer.server.port=3000"
|
|
extra_hosts:
|
|
- "calcom.$DOMAIN:$EXTERNALIP"
|
|
- "nextcloud.$DOMAIN:$EXTERNALIP"
|
|
|
|
networks:
|
|
core:
|
|
external: true
|
|
EOF
|
|
|
|
NEXTAUTH_SECRET=`openssl rand -base64 32`
|
|
CALENDSO_ENCRYPTION_KEY=`dd if=/dev/urandom bs=1K count=1 2>/dev/null | md5sum | awk '{ print $1 }'`
|
|
|
|
cat > /federated/apps/calcom/.env <<EOF
|
|
IMAGE_VERSION=$(current_version calcom)
|
|
|
|
# Set this value to 'agree' to accept our license:
|
|
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
|
|
#
|
|
# Summary of terms:
|
|
# - The codebase has to stay open source, whether it was modified or not
|
|
# - You can not repackage or sell the codebase
|
|
# - Acquire a commercial license to remove these terms by emailing: license@cal.com
|
|
NEXT_PUBLIC_LICENSE_CONSENT=agree
|
|
LICENSE=
|
|
# BASE_URL and NEXT_PUBLIC_APP_URL are both deprecated. Both are replaced with one variable, NEXT_PUBLIC_WEBAPP_URL
|
|
# BASE_URL=http://localhost:3000
|
|
BASE_URL=https://calcom.$DOMAIN
|
|
# NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
NEXT_PUBLIC_APP_URL=https://calcom.$DOMAIN
|
|
NEXT_PUBLIC_WEBAPP_URL=https://calcom.$DOMAIN
|
|
# saint@fed 20241127 Comment out at this time.
|
|
#NEXT_PUBLIC_API_V2_URL=http://calcom.$DOMAIN:5555/api/v2
|
|
# saint@fed 20241204 Try this.
|
|
NEXT_PUBLIC_API_V2_URL=https://calcom.$DOMAIN/api/v2
|
|
# Configure NEXTAUTH_URL manually if needed, otherwise it will resolve to {NEXT_PUBLIC_WEBAPP_URL}/api/auth
|
|
# saint@fed 20241127 Instead of using the below we set internal /etc/hosts hostname to the external IP since /api is needed both internally and by client (and pass :3000 through instead)
|
|
#NEXTAUTH_URL=http://calcom.$DOMAIN:3000/api/auth
|
|
#NEXTAUTH_URL=http://localhost:3000/api/auth
|
|
# It is highly recommended that the NEXTAUTH_SECRET must be overridden and very unique
|
|
# Use "openssl rand -base64 32" to generate a key
|
|
#NEXTAUTH_SECRET=secret
|
|
NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
|
# Encryption key that will be used to encrypt CalDAV credentials, choose a random string, for example with "dd if=/dev/urandom bs=1K count=1 | md5sum"
|
|
#CALENDSO_ENCRYPTION_KEY=secret
|
|
CALENDSO_ENCRYPTION_KEY=$CALENDSO_ENCRYPTION_KEY
|
|
POSTGRES_USER=calcom
|
|
POSTGRES_PASSWORD=$CALCOM_SECRET
|
|
POSTGRES_DB=calcom
|
|
DATABASE_HOST=postgresql.$DOMAIN:5432
|
|
DATABASE_URL=postgresql://calcom:$CALCOM_SECRET@postgresql.$DOMAIN:5432/calcom
|
|
# Needed to run migrations while using a connection pooler like PgBouncer
|
|
# Use the same one as DATABASE_URL if you're not using a connection pooler
|
|
DATABASE_DIRECT_URL=postgresql://calcom:$CALCOM_SECRET@postgresql.$DOMAIN:5432/calcom
|
|
GOOGLE_API_CREDENTIALS={}
|
|
# Set this to '1' if you don't want Cal to collect anonymous usage
|
|
CALCOM_TELEMETRY_DISABLED=1
|
|
# Used for the Office 365 / Outlook.com Calendar integration
|
|
MS_GRAPH_CLIENT_ID=
|
|
MS_GRAPH_CLIENT_SECRET=
|
|
ZOOM_CLIENT_ID=
|
|
# E-mail settings
|
|
# Configures the global From: header whilst sending emails.
|
|
EMAIL_FROM=calcom@$DOMAIN
|
|
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
|
|
EMAIL_SERVER_HOST=mail.$DOMAIN
|
|
EMAIL_SERVER_PORT=587
|
|
EMAIL_SERVER_USER=$SMTPUSER
|
|
EMAIL_SERVER_PASSWORD=$ADMINPASS
|
|
NODE_ENV=production
|
|
# saint@fed 20241127 Comment out due to bug https://github.com/calcom/cal.com/issues/12201 and https://github.com/calcom/cal.com/issues/13572
|
|
# saint@fed 20241127 Commenting out unfortunately triggers another bug though (which appears bearable however): https://github.com/calcom/cal.com/issues/11330
|
|
#ALLOWED_HOSTNAMES='"$DOMAIN"'
|
|
# saint@fed 20241127 Address potential TLS issues by adding this.
|
|
NODE_TLS_REJECT_UNAUTHORIZED=0
|
|
# saint@fed 20241127 Enable full debug with the following...
|
|
#DEBUG=*
|
|
#NEXTAUTH_DEBUG=true
|
|
#CALENDSO_LOG_LEVEL=debug
|
|
# saint@fed 20241127 Authelia OIDC SSO support
|
|
SAML_DATABASE_URL=postgresql://calcom:$CALCOM_SECRET@postgresql.$DOMAIN:5432/calcomsaml
|
|
SAML_ADMINS=admin@$DOMAIN
|
|
EOF
|
|
|
|
chmod 600 /federated/apps/calcom/.env
|
|
|
|
# Make data and data/root/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 <unistd.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <time.h>
|
|
#include <ctype.h>
|
|
#include <assert.h>
|
|
#include <crypt.h>
|
|
|
|
// 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 <password> [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
|
|
DISTRO="$(cat /etc/os-release|grep ^ID= |cut -d= -f2 |sed -e 's,^",,;s,"$,,')"
|
|
if [ "$DISTRO" = "ubuntu" ]; then
|
|
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
|
|
elif [ "$DISTRO" = "openmandriva" ]; then
|
|
dnf -y --refresh install clang 'pkgconfig(libcrypt)' glibc-devel
|
|
clang -Os -march=native -o /federated/apps/calcom/data/root/federated/static_crypt /federated/apps/calcom/data/root/federated/static_crypt.c -lcrypt
|
|
fi
|
|
|
|
# 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/root/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 "Now modify the text for /getting-started"
|
|
|
|
# Handle getting-started wizard modifications (first level)
|
|
WIZARD_FILES=(
|
|
"/calcom/apps/web/.next/server/pages/getting-started/[[...step]].js"
|
|
"/calcom/apps/web/.next/standalone/apps/web/.next/server/pages/getting-started/[[...step]].js"
|
|
)
|
|
|
|
for file in "${WIZARD_FILES[@]}"; do
|
|
if [ -f "$file" ]; then
|
|
echo "Processing wizard file $file"
|
|
|
|
TIMESTAMP=$(date +%s)
|
|
cp "$file" "$file.${TIMESTAMP}"
|
|
sed -i 's/`${c("edit_form_later_subtitle")}`/`NOTE: Username and Full name changes need to be made in the Panel of your Federated Core.`/' "$file"
|
|
# Loks like only two array fields displayed? sed -i 's/subtitle:\[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`\]/subtitle:[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`," NOTE: Username and Full name changes need to be made in the Panel of your Federated Core."]/g' "$file"
|
|
# broken change sed -i 's/subtitle:\[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`\]/subtitle:[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`,"\n\nNOTE: Username and Full name changes need to be made in your Core'\''s Panel."]/g' "$BEAUTIFIED_WIZARD"
|
|
# did nothing sed -i 's/subtitle:\[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`\]/subtitle:[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`,'"' NOTE: Username and Full name changes need to be made in your Core\\'s Panel.'"]/g' "$file"
|
|
|
|
if ! diff -q "$file.${TIMESTAMP}" "$file" >/dev/null; then
|
|
echo "Successfully modified minified code"
|
|
else
|
|
echo "Warning: No changes made to $file"
|
|
fi
|
|
else
|
|
echo "Warning: Wizard file not found: $file"
|
|
fi
|
|
done
|
|
|
|
# Handle translation modifications (second level)
|
|
echo "Modifying translations..."
|
|
LOCALES_DIR="/calcom/apps/web/public/static/locales"
|
|
STANDALONE_LOCALES_DIR="/calcom/apps/web/.next/standalone/apps/web/public/static/locales"
|
|
WARNING_MESSAGE="NOTE: Username and Full name changes need to be made in the Panel of your Federated Core."
|
|
|
|
# Function to modify translation files
|
|
modify_translations() {
|
|
local dir=$1
|
|
if [ -d "$dir" ]; then
|
|
for lang_dir in "$dir"/*/; do
|
|
if [ -f "${lang_dir}common.json" ]; then
|
|
echo "Modifying translation for $(basename "$lang_dir")"
|
|
# Backup original
|
|
cp "${lang_dir}common.json" "${lang_dir}common.json.bak.$(date +%s)"
|
|
# Update translation
|
|
sed -i 's#"edit_form_later_subtitle": *"[^"]*"#"edit_form_later_subtitle": "'"$WARNING_MESSAGE"'"#' "${lang_dir}common.json"
|
|
sed -i 's#"welcome_instructions": *"[^"]*"#"welcome_instructions": "'"$WARNING_MESSAGE"'"#' "${lang_dir}common.json"
|
|
fi
|
|
done
|
|
else
|
|
echo "Warning: Locales directory not found: $dir"
|
|
fi
|
|
}
|
|
|
|
# Modify both main and standalone translations
|
|
modify_translations "$LOCALES_DIR"
|
|
modify_translations "$STANDALONE_LOCALES_DIR"
|
|
|
|
# Catch third level issues too.
|
|
# Find and modify all instances in built files
|
|
# find /calcom/apps/web/.next -type f -exec sed -i 's/You.*ll be able to edit this later/NOTE: Username and Full name changes need to be made in the Panel of your Federated Core/g' {} \;
|
|
# find /calcom/apps/web/.next/standalone/apps/web/.next -type f -exec sed -i 's/You.*ll be able to edit this later/NOTE: Username and Full name changes need to be made in the Panel of your Federated Core/g' {} \;
|
|
|
|
echo "All modifications complete"
|
|
EOOF
|
|
|
|
chmod 755 /federated/apps/calcom/data/root/federated/fix-apiwebui.sh
|
|
|
|
# Add docker-compose image wrapper startup script
|
|
cat > /federated/apps/calcom/data/root/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/root/federated/init.sh
|
|
|
|
# Ensure packages are installed for python requirements
|
|
DISTRO="$(cat /etc/os-release|grep ^ID= |cut -d= -f2 |sed -e 's,^",,;s,"$,,')"
|
|
if [ "$DISTRO" = "ubuntu" ]; then
|
|
apt update
|
|
apt install -y python3 python3-psycopg2 python3-ldap3
|
|
else
|
|
dnf -y --refresh install python python-psycopg2 python-ldap3 python-pyyaml
|
|
fi
|
|
|
|
# Historic addition to .env file -- to remove
|
|
# cat >> /federated/apps/calcom/.env <<EOF
|
|
# VIRTUAL_PROTO=http
|
|
# VIRTUAL_PORT=3000
|
|
# VIRTUAL_HOST=calcom.$DOMAIN
|
|
# EOF
|
|
|
|
# Historic SPINPID kill -- to remove
|
|
# kill -9 $SPINPID &> /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.py <<'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 <<EOF
|
|
<html>
|
|
<img src="https://www.federated.computer/wp-content/uploads/2023/11/logo.png" alt="" /><br>
|
|
<p>
|
|
<h4>Cal.com is now installed on $DOMAIN</h4>
|
|
<p>
|
|
Here is your applications chart with on how to access this service:<br>
|
|
<p>
|
|
<h4>Applications</h4>
|
|
<style type="text/css">
|
|
.tg {border-collapse:collapse;border-spacing:0;}
|
|
.tg td{border-color:black;border-style:solid;border-width:1px;font-family:Arial, sans-serif;font-size:14px;
|
|
overflow:hidden;padding:10px 5px;word-break:normal;}
|
|
.tg th{border-color:black;border-style:solid;border-width:1px;font-family:Arial, sans-serif;font-size:14px;
|
|
font-weight:normal;overflow:hidden;padding:10px 5px;word-break:normal;}
|
|
.tg .tg-cul6{border-color:inherit;color:#340096;text-align:left;text-decoration:underline;vertical-align:top}
|
|
.tg .tg-acii{background-color:#FFF;border-color:inherit;color:#333;text-align:left;vertical-align:top}
|
|
.tg .tg-0hty{background-color:#000000;border-color:inherit;color:#ffffff;font-weight:bold;text-align:left;vertical-align:top}
|
|
.tg .tg-kwiq{border-color:inherit;color:#000000;text-align:left;vertical-align:top;word-wrap:break-word}
|
|
.tg .tg-0pky{border-color:inherit;text-align:left;vertical-align:top}
|
|
</style>
|
|
<table class="tg" style="undefined;table-layout: fixed; width: 996px">
|
|
<colgroup>
|
|
<col style="width: 101.333333px">
|
|
<col style="width: 203.333333px">
|
|
<col style="width: 282.333333px">
|
|
<col style="width: 185.33333px">
|
|
<col style="width: 78.333333px">
|
|
<col style="width: 220.333333px">
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th class="tg-0hty">Service</th>
|
|
<th class="tg-0hty">Link</th>
|
|
<th class="tg-0hty">User / Pass</th>
|
|
<th class="tg-0hty">Access</th>
|
|
<th class="tg-0hty">Docs</th>
|
|
<th class="tg-0hty">Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td class="tg-kwiq">Cal.com</td>
|
|
<td class="tg-kwiq"><a href="https://calcom.$DOMAIN" target="_blank" rel="noopener noreferrer"><span style="color:#340096">calcom.$DOMAIN</span></a></td>
|
|
<td class="tg-kwiq">admin@$DOMAIN<br>admin password above</td>
|
|
<td class="tg-kwiq">User access is separate from panel. Use the admin account to login and then invite other users</td>
|
|
<td class="tg-kwiq"><a href="https://documentation.federated.computer/docs/getting_started/welcome/" target="_blank" rel="noopener noreferrer"><span style="color:#340096">Click here</span></a></td>
|
|
<td class="tg-kwiq">Cal.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.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<h4>Thanks for your support!</h4>
|
|
<p>
|
|
Thank you for your support of Federated Computer. We really appreciate it and hope you have a very successful
|
|
time with Federated Core.
|
|
<p>
|
|
Again, if we can be of any assistance, please don't hesitate to get in touch.
|
|
<p>
|
|
Support: https://support.federated.computer<br>
|
|
Phone: (970) 722-8715<br>
|
|
Email: support@federated.computer<br>
|
|
<p>
|
|
It's <b>your</b> computer. Let's make it work for you!
|
|
</html>
|
|
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() {
|
|
# Ensure DNS entry is added
|
|
/federated/bin/fix pdnsmysql
|
|
docker exec pdns pdnsutil add-record $DOMAIN calcom A 86400 $EXTERNALIP &> /dev/null
|
|
[ $? -ne 0 ] && fail "Couldn't add dns record for calcom"
|
|
|
|
# Start service with command to make sure it's up before proceeding
|
|
start_service "calcom" "nc -z 192.168.0.48 3000 &> /dev/null" "30"
|
|
|
|
# Run sync-calcomusers for first time to remove first-time wizard
|
|
/federated/bin/sync-calcomusers --verbose
|
|
|
|
# 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.\n"
|
|
}
|