#!/bin/bash # ============================================================================= # adminer_install.sh # Instalacja i integracja Adminera z pluginem da-postgresql (DirectAdmin) # # Założenia bezpieczeństwa: # - Adminer dostępny przez alias /adminer # - Tryb public/private odczytywany runtime z /var/www/html/adminer/runtime/config.json # - Tryb publiczny (ADMINER_PUBLIC=true) pozwala na ręczne logowanie do PostgreSQL # - Tryb prywatny (ADMINER_PUBLIC=false) wymaga ticketu SSO z pluginu # - Ticket SSO ma krótki TTL i jest przypięty do User-Agent # - Logowanie odbywa się tymczasową rolą PostgreSQL tworzoną przez plugin # ============================================================================= set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } sekcja() { echo -e "\n${BOLD}${CYAN}=== $* ===${NC}\n"; } die() { echo -e "${RED}[BŁĄD]${NC} $*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Skrypt musi być uruchomiony jako root." LOGFILE="/var/log/adminer_install_$(date +%Y%m%d_%H%M%S).log" exec 1> >(tee >(sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' >> "$LOGFILE")) 2>&1 info "Pełny log zapisany w: $LOGFILE" ADMINER_URL_PATH="/adminer" ADMINER_VERSION="5.4.2" ADMINER_FLAVOR="pgsql" ADMINER_INSTALL_DIR="/var/www/html/adminer" ADMINER_RUNTIME_DIR="${ADMINER_INSTALL_DIR}/runtime" ADMINER_TICKETS_DIR="${ADMINER_RUNTIME_DIR}/tickets" ADMINER_RUNTIME_CONFIG_FILE="${ADMINER_RUNTIME_DIR}/config.json" ADMINER_CORE_FILE="${ADMINER_INSTALL_DIR}/adminer-core.php" ADMINER_EXTENSION_FILE="${ADMINER_INSTALL_DIR}/adminer-extension.php" ADMINER_INDEX_FILE="${ADMINER_INSTALL_DIR}/index.php" PLUGIN_BUNDLED_ADMINER="/usr/local/directadmin/plugins/da-postgresql/assets/adminer/adminer-${ADMINER_VERSION}-${ADMINER_FLAVOR}.php" PLUGIN_BUNDLED_ADMINER_SHA256="059505abc2b56487d78bbfbeb9485ed8c6a10fe357f4ddec9fc69b1043bff4af" PLUGIN_BUNDLED_EXTENSION="/usr/local/directadmin/plugins/da-postgresql/assets/adminer/adminer-extension.php" PLUGIN_BUNDLED_EXTENSION_SHA256="6e2f098b172838ccb3736506867396dc0954a6383fdd7b7c5e7739ab21baafeb" CB_DIR="/usr/local/directadmin/custombuild" CB_BUILD="${CB_DIR}/build" APACHE_ALIAS_CUSTOM="${CB_DIR}/custom/ap2/conf/extra/httpd-alias.conf" APACHE_ALIAS_SOURCE="/etc/httpd/conf/extra/httpd-alias.conf" APACHE_ALIAS_CONFIGURE="${CB_DIR}/configure/ap2/conf/extra/httpd-alias.conf" PLUGIN_SETTINGS="/usr/local/directadmin/plugins/da-postgresql/plugin-settings.conf" PLUGIN_OWNER="diradmin:diradmin" ADMINER_PUBLIC_DEFAULT="false" ADMINER_PUBLIC_MODE="0" detect_apache_group() { local group_name="" if [[ -f /etc/httpd/conf/httpd.conf ]]; then group_name="$(awk 'tolower($1)=="group" {print $2; exit}' /etc/httpd/conf/httpd.conf | tr -d '[:space:]')" fi if [[ -z "$group_name" && -f /etc/apache2/apache2.conf ]]; then group_name="$(awk 'tolower($1)=="group" {print $2; exit}' /etc/apache2/apache2.conf | tr -d '[:space:]')" fi if [[ -z "$group_name" ]]; then for candidate in apache www-data nobody; do if getent group "$candidate" >/dev/null 2>&1; then group_name="$candidate" break fi done fi [[ -n "$group_name" ]] || die "Nie można wykryć grupy użytkownika Apache." echo "$group_name" } set_setting() { local key="$1" local value="$2" local file="$3" [[ -f "$file" ]] || return 0 sed -i "/^${key}=/d" "$file" || true echo "${key}=${value}" >> "$file" } set_setting_if_missing() { local key="$1" local value="$2" local file="$3" [[ -f "$file" ]] || return 0 if grep -q "^${key}=" "$file"; then return 0 fi echo "${key}=${value}" >> "$file" } bool_is_true() { local v v="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" case "$v" in 1|true|yes|y|on|tak|t) return 0 ;; *) return 1 ;; esac } read_adminer_public_mode() { local raw="$ADMINER_PUBLIC_DEFAULT" if [[ -f "$PLUGIN_SETTINGS" ]]; then local found found="$(awk -F= '/^ADMINER_PUBLIC=/{print $2; exit}' "$PLUGIN_SETTINGS" | tr -d '[:space:]')" if [[ -n "$found" ]]; then raw="$found" fi fi if bool_is_true "$raw"; then ADMINER_PUBLIC_MODE="1" else ADMINER_PUBLIC_MODE="0" fi } write_runtime_public_config() { local public_json="false" if [[ "$ADMINER_PUBLIC_MODE" == "1" ]]; then public_json="true" fi cat > "$ADMINER_RUNTIME_CONFIG_FILE" </dev/null 2>&1; then sha256sum "$path" | awk '{print $1}' return 0 fi if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$path" | awk '{print $1}' return 0 fi if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$path" | awk '{print $NF}' return 0 fi return 1 } render_adminer_wrapper() { cat > "$ADMINER_INDEX_FILE" <<'PHPEOF' 0, 'path' => '/adminer', 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), 'httponly' => true, 'samesite' => 'Lax', ]); session_start(); $now = time(); $adminerPublic = load_public_mode(); if (isset($_GET['logout'])) { clear_session(); if (!$adminerPublic) { render_placeholder('Sesja Adminera zostala zamknieta. Otworz Adminera ponownie z panelu DirectAdmin.'); } } $auth = null; $ticketId = ticket_id_from_request(); if ($ticketId !== '') { $ticketAccepted = false; $ticketRejectReason = ''; $ticketBinding = ''; $ticket = load_ticket($ticketId); if ($ticket !== null) { $ticketBinding = isset($ticket['user_binding']) && is_string($ticket['user_binding']) ? $ticket['user_binding'] : ''; } if ($ticket === null) { $ticketRejectReason = 'ticket_missing_or_unreadable'; } elseif (validate_ticket($ticket, $now, $adminerPublic, $ticketRejectReason)) { $auth = [ 'host' => 'localhost', 'port' => (int)($ticket['port'] ?? 5432), 'user' => (string)($ticket['role'] ?? ''), 'password' => (string)($ticket['password'] ?? ''), 'database' => (string)($ticket['database'] ?? ''), 'expires_at' => (int)($ticket['session_expires_at'] ?? ($now + 900)), ]; $_SESSION[DA_ADMINER_SESSION_KEY] = $auth; @unlink(ticket_path($ticketId)); clear_ticket_from_request(); $ticketAccepted = true; } debug_ticket_event($ticketId, $ticketAccepted, $ticketRejectReason, $ticketBinding, current_user_binding()); if (!$ticketAccepted && !$adminerPublic) { render_placeholder('Nieprawidlowy lub wygasly link logowania. Otworz Adminera z poziomu pluginu.'); } } if ($auth === null) { $auth = $_SESSION[DA_ADMINER_SESSION_KEY] ?? null; } if (!is_array($auth) || empty($auth['user']) || empty($auth['password'])) { $auth = null; if (!$adminerPublic) { render_placeholder(); } } if ($auth !== null) { $expiresAt = (int)($auth['expires_at'] ?? 0); if ($expiresAt <= 0 || $expiresAt < $now) { clear_session(); $auth = null; if (!$adminerPublic) { render_placeholder('Sesja Adminera wygasla. Otworz Adminera ponownie z poziomu pluginu.'); } } } if (has_forbidden_driver_params()) { clear_session(); http_response_code(403); render_placeholder('Dozwolony jest tylko sterownik PostgreSQL.'); } $_GET['pgsql'] = 'localhost'; if ($auth !== null) { bootstrap_adminer_session($auth); unset($_POST['auth']); } else { normalize_post_auth(); } $GLOBALS['da_adminer_sso_auth'] = $auth; require DA_ADMINER_EXTENSION_FILE; require DA_ADMINER_CORE_FILE; exit; function ticket_id_from_request(): string { $ticket = $_GET['ticket'] ?? ''; if (!is_string($ticket)) { return ''; } $ticket = trim($ticket); if ($ticket === '' || !preg_match('/^[A-Za-z0-9_-]{20,128}$/', $ticket)) { return ''; } return $ticket; } function ticket_path(string $ticketId): string { return DA_ADMINER_TICKETS_DIR . '/' . $ticketId . '.json'; } function clear_ticket_from_request(): void { unset($_GET['ticket']); $uri = $_SERVER['REQUEST_URI'] ?? ''; if (!is_string($uri) || $uri === '') { return; } $parts = parse_url($uri); if (!is_array($parts)) { return; } $path = isset($parts['path']) && is_string($parts['path']) ? $parts['path'] : '/adminer'; $query = []; if (isset($parts['query']) && is_string($parts['query']) && $parts['query'] !== '') { parse_str($parts['query'], $query); if (is_array($query)) { unset($query['ticket']); } else { $query = []; } } $newQuery = http_build_query($query); $_SERVER['REQUEST_URI'] = $path . ($newQuery !== '' ? ('?' . $newQuery) : ''); } function load_ticket(string $ticketId): ?array { $path = ticket_path($ticketId); if (!is_file($path) || !is_readable($path)) { return null; } $raw = @file_get_contents($path); if (!is_string($raw) || $raw === '') { return null; } $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : null; } function load_public_mode(): bool { $default = (DA_ADMINER_PUBLIC_DEFAULT === 1); if (!is_file(DA_ADMINER_CONFIG_FILE) || !is_readable(DA_ADMINER_CONFIG_FILE)) { return $default; } $raw = @file_get_contents(DA_ADMINER_CONFIG_FILE); if (!is_string($raw) || $raw === '') { return $default; } $decoded = json_decode($raw, true); if (!is_array($decoded) || !array_key_exists('adminer_public', $decoded)) { return $default; } return parse_bool_value($decoded['adminer_public'], $default); } function parse_bool_value($value, bool $default = false): bool { if (is_bool($value)) { return $value; } if (is_int($value) || is_float($value)) { return ((int)$value) !== 0; } if (!is_string($value)) { return $default; } $normalized = strtolower(trim($value)); if (in_array($normalized, ['1', 'true', 'yes', 'y', 'on', 'tak', 't'], true)) { return true; } if (in_array($normalized, ['0', 'false', 'no', 'n', 'off', 'nie'], true)) { return false; } return $default; } function validate_ticket(array $ticket, int $now, bool $adminerPublic, string &$reason = ''): bool { $required = ['role', 'password', 'database', 'host', 'port', 'expires_at', 'session_expires_at', 'user_binding']; foreach ($required as $key) { if (!array_key_exists($key, $ticket)) { $reason = 'missing_key:' . $key; return false; } } $expiresAt = is_numeric($ticket['expires_at']) ? (int)$ticket['expires_at'] : 0; if ($expiresAt < $now) { $reason = 'ticket_expired'; return false; } $sessionExpiresAt = is_numeric($ticket['session_expires_at']) ? (int)$ticket['session_expires_at'] : 0; if ($sessionExpiresAt < $now) { $reason = 'session_expired'; return false; } $role = (string)$ticket['role']; if ($role === '' || !preg_match('/^[a-z0-9_]{1,63}$/', $role)) { $reason = 'invalid_role'; return false; } $binding = (string)$ticket['user_binding']; if (!is_binding_valid($binding, current_user_binding(), $adminerPublic)) { $reason = 'binding_mismatch'; return false; } $reason = 'ok'; return true; } function current_user_binding(): string { $ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? ''); return hash('sha256', $ua); } function is_binding_valid(string $ticketBinding, string $currentBinding, bool $adminerPublic): bool { if ($adminerPublic) { return true; } if ($ticketBinding === '') { return false; } if (hash_equals($ticketBinding, $currentBinding)) { return true; } // Kompatybilność: starsze ticket-y mogły być wystawione bez HTTP_USER_AGENT po stronie DA. $legacyNoUaBinding = hash('sha256', ''); if (hash_equals($ticketBinding, $legacyNoUaBinding)) { return true; } return false; } function debug_ticket_event( string $ticketId, bool $accepted, string $reason, string $ticketBinding, string $currentBinding ): void { $line = sprintf( "[%s] ticket=%s accepted=%s reason=%s ticket_binding=%s current_binding=%s remote=%s ua=%s\n", date('c'), $ticketId, $accepted ? '1' : '0', $reason !== '' ? $reason : 'n/a', $ticketBinding !== '' ? $ticketBinding : '-', $currentBinding !== '' ? $currentBinding : '-', (string)($_SERVER['REMOTE_ADDR'] ?? '-'), (string)($_SERVER['HTTP_USER_AGENT'] ?? '-') ); @error_log($line, 3, '/tmp/da_adminer_wrapper_debug.log'); } function has_forbidden_driver_params(): bool { $forbidden = ['server', 'sqlite', 'mongo', 'mssql', 'oracle', 'elastic', 'clickhouse']; foreach ($forbidden as $key) { if (isset($_GET[$key])) { return true; } } return false; } function bootstrap_adminer_session(array $auth): void { $driver = 'pgsql'; $server = 'localhost'; $user = trim((string)($auth['user'] ?? '')); $password = (string)($auth['password'] ?? ''); $database = trim((string)($auth['database'] ?? '')); if ($user === '' || $password === '') { return; } $_GET[$driver] = $server; $_GET['username'] = $user; if ($database !== '' && (!isset($_GET['db']) || !is_string($_GET['db']) || $_GET['db'] === '')) { $_GET['db'] = $database; } $_SESSION['pwds'][$driver][$server][$user] = $password; if ($database !== '') { $_SESSION['db'][$driver][$server][$user][$database] = true; } } function normalize_post_auth(): void { if (!isset($_POST['auth']) || !is_array($_POST['auth'])) { return; } $_POST['auth']['driver'] = 'pgsql'; $_POST['auth']['server'] = 'localhost'; } function clear_session(): void { $_SESSION = []; if (session_status() === PHP_SESSION_ACTIVE) { session_unset(); session_destroy(); } } function render_placeholder(string $extra = ''): void { http_response_code(200); $message = 'Dostep do Adminera jest mozliwy wylacznie z poziomu panelu DirectAdmin.'; echo ''; echo 'Adminer - DirectAdmin'; echo ''; echo '

Adminer

' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '

'; if ($extra !== '') { echo '

' . htmlspecialchars($extra, ENT_QUOTES, 'UTF-8') . '

'; } echo '

W panelu pluginu PostgreSQL kliknij przycisk "Adminer", aby otworzyc sesje SSO.

'; exit; } PHPEOF sed -i "s/__ADMINER_PUBLIC_MODE__/${ADMINER_PUBLIC_MODE}/g" "$ADMINER_INDEX_FILE" } configure_apache_alias() { [[ -d "$CB_DIR" ]] || die "Brak katalogu CustomBuild: ${CB_DIR}" mkdir -p "$(dirname "$APACHE_ALIAS_CUSTOM")" if [[ ! -f "$APACHE_ALIAS_CUSTOM" ]]; then if [[ -f "$APACHE_ALIAS_SOURCE" ]]; then cp -p "$APACHE_ALIAS_SOURCE" "$APACHE_ALIAS_CUSTOM" elif [[ -f "$APACHE_ALIAS_CONFIGURE" ]]; then cp -p "$APACHE_ALIAS_CONFIGURE" "$APACHE_ALIAS_CUSTOM" else warn "Nie znaleziono źródłowego httpd-alias.conf – tworzę pusty custom template." : > "$APACHE_ALIAS_CUSTOM" fi fi local tmp_file tmp_file="$(mktemp)" awk ' BEGIN { skip = 0 } /^# BEGIN DA_POSTGRESQL_ADMINER$/ { skip = 1; next } /^# END DA_POSTGRESQL_ADMINER$/ { skip = 0; next } skip == 0 { print } ' "$APACHE_ALIAS_CUSTOM" > "$tmp_file" cat >> "$tmp_file" < AllowOverride None Options -Indexes Require all granted Require all denied AllowOverride None Options -Indexes Require all denied # END DA_POSTGRESQL_ADMINER EOF mv "$tmp_file" "$APACHE_ALIAS_CUSTOM" chmod 644 "$APACHE_ALIAS_CUSTOM" } sekcja "Weryfikacja środowiska" command -v php >/dev/null 2>&1 || die "Brak polecenia php." id diradmin >/dev/null 2>&1 || die "Brak użytkownika diradmin." APACHE_GROUP="$(detect_apache_group)" info "Wykryta grupa Apache: ${APACHE_GROUP}" read_adminer_public_mode if [[ "$ADMINER_PUBLIC_MODE" == "1" ]]; then info "Tryb ADMINER_PUBLIC: true (bezpośredni dostęp do /adminer bez SSO)." else info "Tryb ADMINER_PUBLIC: false (dostęp wyłącznie przez SSO z pluginu)." fi sekcja "Instalacja z bundla Adminera ${ADMINER_VERSION}-${ADMINER_FLAVOR}" mkdir -p "$ADMINER_INSTALL_DIR" [[ -f "$PLUGIN_BUNDLED_ADMINER" ]] || die "Brak zbundlowanego pliku Adminera: ${PLUGIN_BUNDLED_ADMINER}" [[ -f "$PLUGIN_BUNDLED_EXTENSION" ]] || die "Brak zbundlowanego rozszerzenia Adminera: ${PLUGIN_BUNDLED_EXTENSION}" EXPECTED_SUM="$PLUGIN_BUNDLED_ADMINER_SHA256" ACTUAL_SUM="$(sha256_file "$PLUGIN_BUNDLED_ADMINER" || true)" [[ -n "$ACTUAL_SUM" ]] || die "Nie można policzyć SHA256 dla ${PLUGIN_BUNDLED_ADMINER}." [[ "$ACTUAL_SUM" == "$EXPECTED_SUM" ]] \ || die "Niezgodna suma SHA256 dla zbundlowanego Adminera. Oczekiwano: ${EXPECTED_SUM}, otrzymano: ${ACTUAL_SUM}" cp -f "$PLUGIN_BUNDLED_ADMINER" "$ADMINER_CORE_FILE" info "Adminer ${ADMINER_VERSION}-${ADMINER_FLAVOR} skopiowany do: ${ADMINER_CORE_FILE}" EXPECTED_EXTENSION_SUM="$PLUGIN_BUNDLED_EXTENSION_SHA256" ACTUAL_EXTENSION_SUM="$(sha256_file "$PLUGIN_BUNDLED_EXTENSION" || true)" [[ -n "$ACTUAL_EXTENSION_SUM" ]] || die "Nie można policzyć SHA256 dla ${PLUGIN_BUNDLED_EXTENSION}." [[ "$ACTUAL_EXTENSION_SUM" == "$EXPECTED_EXTENSION_SUM" ]] \ || die "Niezgodna suma SHA256 dla zbundlowanego rozszerzenia Adminera. Oczekiwano: ${EXPECTED_EXTENSION_SUM}, otrzymano: ${ACTUAL_EXTENSION_SUM}" cp -f "$PLUGIN_BUNDLED_EXTENSION" "$ADMINER_EXTENSION_FILE" info "Rozszerzenie Adminera skopiowane do: ${ADMINER_EXTENSION_FILE}" sekcja "Konfiguracja wrappera bezpieczeństwa" mkdir -p "$ADMINER_RUNTIME_DIR" "$ADMINER_TICKETS_DIR" render_adminer_wrapper chown root:root "$ADMINER_INSTALL_DIR" chmod 755 "$ADMINER_INSTALL_DIR" chown root:root "$ADMINER_CORE_FILE" "$ADMINER_EXTENSION_FILE" "$ADMINER_INDEX_FILE" chmod 644 "$ADMINER_CORE_FILE" "$ADMINER_EXTENSION_FILE" "$ADMINER_INDEX_FILE" chown diradmin:"$APACHE_GROUP" "$ADMINER_RUNTIME_DIR" "$ADMINER_TICKETS_DIR" chmod 711 "$ADMINER_RUNTIME_DIR" chmod 2733 "$ADMINER_TICKETS_DIR" write_runtime_public_config chown diradmin:"$APACHE_GROUP" "$ADMINER_RUNTIME_CONFIG_FILE" chmod 644 "$ADMINER_RUNTIME_CONFIG_FILE" if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then warn "Wykryto SELinux: ustawiam kontekst zapisu runtime dla Apache." if command -v chcon >/dev/null 2>&1; then chcon -R -t httpd_sys_rw_content_t "$ADMINER_RUNTIME_DIR" || warn "Nie udało się ustawić kontekstu SELinux dla runtime." chcon -t httpd_sys_content_t "$ADMINER_CORE_FILE" "$ADMINER_EXTENSION_FILE" "$ADMINER_INDEX_FILE" || warn "Nie udało się ustawić kontekstu SELinux dla plików PHP." else warn "Brak chcon - ustaw kontekst SELinux ręcznie." fi fi sekcja "Konfiguracja aliasu /adminer (DirectAdmin CustomBuild)" configure_apache_alias if [[ -x "$CB_BUILD" ]]; then (cd "$CB_DIR" && ./build rewrite_confs) \ || die "rewrite_confs zakończył się błędem." info "rewrite_confs wykonane pomyślnie." else warn "Brak ${CB_BUILD} - uruchom ręcznie: cd ${CB_DIR} && ./build rewrite_confs" fi if systemctl is-active --quiet httpd 2>/dev/null; then systemctl reload httpd || warn "Nie udało się przeładować httpd." fi sekcja "Aktualizacja ustawień pluginu" if [[ -f "$PLUGIN_SETTINGS" ]]; then set_setting "ENABLE_ADMINER" "true" "$PLUGIN_SETTINGS" set_setting_if_missing "ADMINER_PUBLIC" "false" "$PLUGIN_SETTINGS" set_setting_if_missing "ADMINER_PUBLIC_BASE_URL" "" "$PLUGIN_SETTINGS" set_setting "ADMINER_URL_PATH" "$ADMINER_URL_PATH" "$PLUGIN_SETTINGS" set_setting "ADMINER_SSO_DIR" "$ADMINER_RUNTIME_DIR" "$PLUGIN_SETTINGS" set_setting "ADMINER_TICKET_TTL" "120" "$PLUGIN_SETTINGS" set_setting "ADMINER_SESSION_TTL" "900" "$PLUGIN_SETTINGS" set_setting "ADMINER_ROLE_TTL" "1200" "$PLUGIN_SETTINGS" chown "$PLUGIN_OWNER" "$PLUGIN_SETTINGS" 2>/dev/null || true chmod 640 "$PLUGIN_SETTINGS" 2>/dev/null || true info "Zaktualizowano: ${PLUGIN_SETTINGS}" else warn "Nie znaleziono pliku ${PLUGIN_SETTINGS} - ustawienia pluginu pominięte." fi sekcja "Instalacja zakończona" echo "Adminer URL: ${ADMINER_URL_PATH}" echo "Katalog: ${ADMINER_INSTALL_DIR}" echo "Runtime ticketów: ${ADMINER_TICKETS_DIR}" echo "Runtime config: ${ADMINER_RUNTIME_CONFIG_FILE}" echo "Alias template: ${APACHE_ALIAS_CUSTOM}" echo "Log: ${LOGFILE}"