#!/usr/bin/env bash # ===================================================================== # Instalador do LK - Mini gestor de hosting via terminal # Inclui o LK Monitor (painel web), instalado pelo menu: lk -> 33 # Uso: # sudo bash install-lk.sh instala / atualiza # sudo bash install-lk.sh uninstall remove o LK # ===================================================================== [[ $EUID -ne 0 ]] && exec sudo bash "$(readlink -f "$0")" "$@" G=$'\e[32m'; R=$'\e[31m'; C=$'\e[36m'; N=$'\e[0m' SHARE=/usr/local/share/lk if [[ "${1:-}" == "uninstall" ]]; then systemctl disable --now lk-firewall.service >/dev/null 2>&1 || true rm -f /etc/systemd/system/lk-firewall.service /usr/local/bin/lk /usr/local/bin/LK /usr/bin/lk /usr/bin/LK rm -rf "$SHARE" systemctl daemon-reload || true echo "${G}LK removido.${N} Configurações mantidas em /etc/lk (apague manualmente se quiser)." exit 0 fi command -v apt-get >/dev/null || { echo "${R}O LK suporta apenas Ubuntu/Debian.${N}"; exit 1; } mkdir -p /etc/lk /root/lk-backups "$SHARE" chmod 700 /etc/lk /root/lk-backups touch /etc/lk/blocked_ips.txt TMP=$(mktemp) echo "${C}[..]${N} Instalando o comando lk..." cat > "$TMP" <<'LK_SCRIPT_EOF' #!/usr/bin/env bash # ===================================================================== # LK - Mini gestor de hosting via terminal (Ubuntu / Debian) # Uso: lk -> menu interativo # lk help -> comandos diretos # ===================================================================== LK_VERSION="2.3.0" LKM_INSTALLER="/usr/local/share/lk/install-lk-monitor.sh" LKM_SITE="/etc/apache2/sites-available/lkmonitor.conf" LK_DIR="/etc/lk" LK_BLOCKLIST="$LK_DIR/blocked_ips.txt" LK_DBFILE="$LK_DIR/databases.txt" LK_BACKUPS="/root/lk-backups" LK_LOG="/var/log/lk.log" WWW="/var/www" APACHE_AV="/etc/apache2/sites-available" APACHE_EN="/etc/apache2/sites-enabled" DEFAULT_ROOT="/var/www/default" LK_CLI="" if [[ -t 1 ]]; then R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; C=$'\e[36m'; W=$'\e[1m'; D=$'\e[2m'; N=$'\e[0m' else R=""; G=""; Y=""; C=""; W=""; D=""; N="" fi # --------------------------------------------------------------------- utilidades log() { echo "$(date '+%F %T') $*" >> "$LK_LOG" 2>/dev/null || true; } ok() { echo "${G}[OK]${N} $*"; log "OK: $*"; } err() { echo "${R}[ERRO]${N} $*" >&2; log "ERRO: $*"; } info() { echo "${C}[..]${N} $*"; } warn() { echo "${Y}[!]${N} $*"; } title() { echo; echo "${W}${C}== $* ==${N}"; } has() { command -v "$1" >/dev/null 2>&1; } svc_has() { systemctl cat "$1" >/dev/null 2>&1; } is_aapanel() { [[ -d /www/server/panel ]]; } apt_i() { DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"; } pause() { [[ -n "$LK_CLI" ]] && return 0; echo; read -rp "ENTER para voltar ao menu..." _ ; } ask() { local q="$1" def="${2:-}" a if [[ -n "$def" ]]; then read -rp "$q [$def]: " a; echo "${a:-$def}" else read -rp "$q: " a; echo "$a"; fi; } confirm() { local a; read -rp "$1 [s/N]: " a; [[ "$a" =~ ^[sSyY]$ ]]; } confirm_yes() { local a; read -rp "$1 [S/n]: " a; [[ ! "$a" =~ ^[nN]$ ]]; } gen_pass() { tr -dc 'A-Za-z0-9' /dev/null | head -c 20; } html_esc() { sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' <<< "$1"; } follow() { info "Ctrl+C para parar"; trap 'echo' INT; "$@"; trap - INT; } load_os() { OS_ID=""; OS_NAME="Linux" if [[ -r /etc/os-release ]]; then OS_ID=$(. /etc/os-release; echo "${ID:-}") OS_NAME=$(. /etc/os-release; echo "${PRETTY_NAME:-Linux}") fi } php_versions() { ls /etc/php 2>/dev/null | grep -E '^[0-9]+\.[0-9]+$' | sort -V; } php_default() { has php && php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null; } fpm_services() { systemctl list-unit-files --no-legend --type=service 'php*fpm*' 2>/dev/null | awk '{print $1}' | sed 's/\.service$//' | sort -u; } apache_svc() { local s; for s in apache2 httpd; do svc_has "$s" && { echo "$s"; return 0; }; done; return 1; } db_svc() { local s; for s in mariadb mysql mysqld; do svc_has "$s" && { echo "$s"; return 0; }; done; return 1; } choose_from() { # choose_from "titulo" item1 item2 ... -> echo item escolhido (padrão = último) local t="$1"; shift; local items=("$@") i o ((${#items[@]})) || return 1 if ((${#items[@]} == 1)); then echo "${items[0]}"; return 0; fi echo "$t" >&2 for i in "${!items[@]}"; do echo " ($((i+1))) ${items[i]}" >&2; done o=$(ask "Escolha" "${#items[@]}") if [[ $o =~ ^[0-9]+$ ]] && ((o >= 1 && o <= ${#items[@]})); then echo "${items[o-1]}"; else echo "${items[-1]}"; fi } pick_php() { # versão com FPM local vs=() v for v in $(php_versions); do [[ -d /etc/php/$v/fpm ]] && vs+=("$v"); done ((${#vs[@]})) || { err "Nenhum PHP-FPM instalado (use a opção 1)."; return 1; } choose_from "Versões do PHP-FPM:" "${vs[@]}" } pick_php_bin() { local bins=() b for b in /usr/bin/php[0-9]*.[0-9]*; do [[ -x $b ]] && bins+=("$b"); done if ((${#bins[@]} == 0)); then command -v php; return; fi choose_from "Binário do PHP:" "${bins[@]}" } need_apache() { [[ -d /etc/apache2 ]] && return 0 if is_aapanel; then err "Este servidor usa o Apache do aaPanel. Gerencie os sites pelo painel." else err "Apache não instalado (use a opção 1)."; fi return 1 } apache_test() { if has apache2ctl; then apache2ctl configtest elif [[ -x /www/server/apache/bin/apachectl ]]; then /www/server/apache/bin/apachectl -t else return 0; fi } apache_reload() { local out if ! out=$(apache_test 2>&1); then err "Configuração do Apache com erro:"; echo "$out"; return 1; fi systemctl reload "$(apache_svc)" && return 0 } # --------------------------------------------------------------------- serviços svc_restart() { local s for s in "$@"; do if svc_has "$s"; then info "Reiniciando $s..." if systemctl restart "$s"; then ok "$s reiniciado" else err "Falha ao reiniciar $s"; systemctl status "$s" --no-pager -l 2>/dev/null | tail -n 15; fi else warn "Serviço $s não encontrado"; fi done } restart_apache() { local s out s=$(apache_svc) || { warn "Apache não encontrado"; return 1; } if ! out=$(apache_test 2>&1); then err "Configuração com erro, restart cancelado:"; echo "$out"; return 1; fi svc_restart "$s" } restart_db() { local s; s=$(db_svc) || { warn "MariaDB/MySQL não encontrado"; return 1; }; svc_restart "$s"; } restart_php() { local l; l=$(fpm_services); [[ -n $l ]] || { warn "Nenhum PHP-FPM encontrado"; return 1; }; svc_restart $l; } restart_lamp() { restart_php; restart_db; restart_apache; } known_services() { local s for s in apache2 httpd nginx; do svc_has "$s" && echo "$s"; done if svc_has mariadb; then echo mariadb; else for s in mysql mysqld; do svc_has "$s" && { echo "$s"; break; }; done; fi fpm_services for s in redis-server supervisor cron ssh fail2ban docker pm2-root; do svc_has "$s" && echo "$s"; done } svc_manage() { title "Gerenciar serviço" local list=() s i o a mapfile -t list < <(known_services) ((${#list[@]})) || { warn "Nenhum serviço conhecido encontrado"; return 1; } for i in "${!list[@]}"; do printf " (%d) %-22s %s\n" $((i+1)) "${list[i]}" "$(systemctl is-active "${list[i]}" 2>/dev/null)"; done o=$(ask "Serviço (número ou nome)") if [[ $o =~ ^[0-9]+$ ]]; then ((o >= 1)) && s="${list[o-1]:-}"; else s="$o"; fi if [[ -z $s ]] || ! svc_has "$s"; then err "Serviço inválido"; return 1; fi echo " (1) Iniciar (2) Parar (3) Reiniciar (4) Recarregar (5) Status (6) Ativar no boot (7) Desativar no boot" a=$(ask "Ação" 3) if [[ $s == ssh && $a =~ ^(2|7)$ ]]; then confirm "Parar o SSH pode te deixar sem acesso. Continuar?" || return 1; fi case $a in 1) systemctl start "$s";; 2) systemctl stop "$s";; 3) systemctl restart "$s";; 4) systemctl reload "$s";; 5) systemctl status "$s" --no-pager -l | head -n 25; return 0;; 6) systemctl enable "$s";; 7) systemctl disable "$s";; *) err "Ação inválida"; return 1;; esac && ok "$s: $(systemctl is-active "$s" 2>/dev/null)" } show_status() { title "Status dos serviços" local s st for s in $(known_services); do st=$(systemctl is-active "$s" 2>/dev/null) if [[ $st == active ]]; then printf " %-22s %s● ativo%s\n" "$s" "$G" "$N" else printf " %-22s %s○ %s%s\n" "$s" "$R" "${st:-desconhecido}" "$N"; fi done has pm2 && echo " PM2: $(pm2 jlist 2>/dev/null | grep -o '"pm_id"' | wc -l) app(s)" echo " IPs bloqueados: $(blocked_count)" } # --------------------------------------------------------------------- LAMP add_php_repo() { local code; code=$(. /etc/os-release; echo "${VERSION_CODENAME:-}") if [[ $OS_ID == ubuntu ]]; then apt_i software-properties-common >/dev/null && add-apt-repository -y ppa:ondrej/php >/dev/null || return 1 elif [[ $OS_ID == debian ]]; then curl -fsSLo /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb \ && dpkg -i /tmp/debsuryorg-archive-keyring.deb >/dev/null || return 1 echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $code main" > /etc/apt/sources.list.d/php.list else err "Repositório PHP extra só para Ubuntu/Debian"; return 1 fi apt-get update -qq } install_lamp() { title "Instalar LAMP" has apt-get || { err "Apenas Ubuntu/Debian"; return 1; } if is_aapanel; then warn "aaPanel detectado: ele já gerencia Apache/MySQL/PHP (portas 80, 443, 3306)." warn "Instalar o LAMP do sistema junto causa conflito de portas." [[ ${LK_AUTO:-0} == 1 ]] && return 1 confirm "Continuar mesmo assim?" || return 1 fi local o=1 v="" p if [[ ${LK_AUTO:-0} != 1 ]]; then echo "Versão do PHP:" echo " (1) Padrão do sistema (2) 8.1 (3) 8.2 (4) 8.3 (5) 8.4" o=$(ask "Escolha" 1) fi case $o in 2) v=8.1;; 3) v=8.2;; 4) v=8.3;; 5) v=8.4;; esac info "Atualizando pacotes..." apt-get update -qq apt_i ca-certificates curl gnupg unzip >/dev/null if [[ -n $v ]]; then add_php_repo || { err "Falha ao adicionar repositório do PHP $v"; return 1; }; fi p="php${v}" info "Instalando Apache, MariaDB e PHP ${v:-padrão}..." apt_i apache2 mariadb-server mariadb-client \ "$p-fpm" "$p-cli" "$p-common" "$p-mysql" "$p-curl" "$p-gd" "$p-mbstring" "$p-xml" \ "$p-zip" "$p-bcmath" "$p-intl" "$p-readline" "$p-soap" "$p-sqlite3" \ || { err "Falha na instalação dos pacotes"; return 1; } [[ -z $v ]] && v=$(php_default) a2dismod "php$v" >/dev/null 2>&1; a2dismod mpm_prefork >/dev/null 2>&1 a2enmod mpm_event proxy_fcgi setenvif rewrite headers ssl expires >/dev/null a2enconf "php$v-fpm" >/dev/null echo "ServerName localhost" > /etc/apache2/conf-available/lk-servername.conf cat > /etc/apache2/conf-available/lk-security.conf <<'EOF' ServerTokens Prod ServerSignature Off TraceEnable Off EOF a2enconf lk-servername lk-security >/dev/null apache_proxy_https noreload cat > "/etc/php/$v/fpm/conf.d/99-lk.ini" <<'EOF' upload_max_filesize = 64M post_max_size = 64M memory_limit = 256M max_execution_time = 120 expose_php = Off EOF systemctl enable --now "php$v-fpm" mariadb apache2 >/dev/null 2>&1 systemctl restart "php$v-fpm" apache2 mysql -uroot -e "DROP USER IF EXISTS ''@'localhost'; DROP USER IF EXISTS ''@'$(hostname)'; DROP DATABASE IF EXISTS test; FLUSH PRIVILEGES;" >/dev/null 2>&1 if has ufw && ufw status 2>/dev/null | grep -q "Status: active"; then ufw allow 80,443/tcp >/dev/null; fi [[ -f $DEFAULT_ROOT/index.html ]] || create_default_page auto ok "LAMP instalado (Apache + MariaDB + PHP $v)" [[ ${LK_AUTO:-0} == 1 ]] && return 0 has composer || { confirm_yes "Instalar o Composer também?" && install_composer; } } # Instalação completa sem perguntas: LAMP (se faltar) + painel com senha temporária (trocada no 1º login) lk_setup() { title "LkPanel - instalação completa" has apt-get || { err "Apenas Ubuntu/Debian"; return 1; } if [[ -f /var/www/lkmonitor/config.php ]]; then ok "O painel já está instalado. Atualizando sem mudar porta, senha nem dados..." bash "$LKM_INSTALLER" atualizar; return fi if ! has apache2 || ! ls /etc/php/*/fpm/pool.d >/dev/null 2>&1; then info "Instalando Apache, PHP e MariaDB (pode levar alguns minutos)..." LK_AUTO=1 install_lamp || { err "Falha ao instalar o LAMP"; return 1; } fi [[ -f $LKM_INSTALLER ]] || { err "Instalador do painel não encontrado em $LKM_INSTALLER. Rode o install-lk.sh mais recente."; return 1; } if ! ssh_installed; then info "Instalando o SSH (o root liga no 1º acesso ao painel)..." apt_i openssh-server >/dev/null 2>&1 && systemctl enable --now "$(ssh_svc)" >/dev/null 2>&1 || warn "Não foi possível instalar o SSH agora (depois: sudo lk ssh)" fi has ufw && ufw status 2>/dev/null | grep -q "Status: active" && ufw allow "$(ssh_port)/tcp" >/dev/null 2>&1 LKM_AUTO=1 bash "$LKM_INSTALLER" instalar } install_composer() { has php || { err "Instale o PHP primeiro (opção 1)"; return 1; } info "Instalando Composer..." curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php \ && php /tmp/composer-setup.php --quiet --install-dir=/usr/local/bin --filename=composer \ && rm -f /tmp/composer-setup.php \ && ok "$(COMPOSER_ALLOW_SUPERUSER=1 composer --version 2>/dev/null | head -n1)" } # --------------------------------------------------------------------- Node / PM2 install_node() { local v; v=$(ask "Versão do Node.js (20, 22, 24)" 22) [[ $v =~ ^[0-9]+$ ]] || { err "Versão inválida"; return 1; } apt_i curl ca-certificates gnupg >/dev/null info "Instalando Node.js $v..." curl -fsSL "https://deb.nodesource.com/setup_${v}.x" | bash - >/dev/null && apt_i nodejs >/dev/null \ && ok "Node.js $(node -v) instalado" || { err "Falha ao instalar Node.js"; return 1; } } install_pm2() { title "Node.js + PM2" if has node; then info "Node.js já instalado: $(node -v)" confirm "Trocar a versão do Node?" && { install_node || return 1; } else install_node || return 1 fi info "Instalando PM2..." npm install -g pm2 >/dev/null 2>&1 || { err "Falha ao instalar PM2"; return 1; } pm2 startup systemd -u root --hp /root >/dev/null 2>&1 pm2 save >/dev/null 2>&1 ok "PM2 $(pm2 -v) instalado e configurado para iniciar no boot" } pm2_need() { has pm2 || { err "PM2 não instalado (opção 8)"; return 1; }; } pm2_start() { pm2_need || return 1 title "Iniciar app no PM2" echo " (1) Arquivo JS (ex: /var/www/api/server.js)" echo " (2) Projeto npm (npm run HTML; } function tfa_endpoint(): void { $act = (string)($_GET['tfa'] ?? ''); $valid = hash_equals((string)($_SESSION['csrf'] ?? ''), (string)($_SERVER['HTTP_X_LKM_TOKEN'] ?? '')); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !$valid) { session_write_close(); json_out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 403); return; } $in = json_decode((string)file_get_contents('php://input'), true); if (!is_array($in)) $in = []; $d = tfa_get(); switch ($act) { case 'status': session_write_close(); json_out(['ok' => true, 'enabled' => tfa_on(), 'since' => $d['since'] ?? null, 'recovery' => count((array)($d['recovery'] ?? []))]); return; case 'start': if (tfa_on()) { session_write_close(); json_out(['ok' => false, 'error' => 'O 2FA já está ligado.']); return; } $secret = b32_enc(random_bytes(20)); $_SESSION['tfa_pending'] = $secret; session_write_close(); $label = rawurlencode('LK Monitor') . ':' . rawurlencode(strtolower(explode('.', (string)gethostname())[0])); json_out(['ok' => true, 'secret' => trim(chunk_split($secret, 4, ' ')), 'uri' => 'otpauth://totp/' . $label . '?secret=' . $secret . '&issuer=' . rawurlencode('LK Monitor') . '&algorithm=SHA1&digits=6&period=30']); return; case 'enable': $secret = (string)($_SESSION['tfa_pending'] ?? ''); if ($secret === '') { session_write_close(); json_out(['ok' => false, 'error' => 'Comece de novo: clique em Ativar 2FA.']); return; } $step = totp_match($secret, preg_replace('/\s+/', '', (string)($in['code'] ?? ''))); if ($step === null) { session_write_close(); json_out(['ok' => false, 'error' => 'Código errado. Confira se o relógio do celular está certo e digite o código que aparece agora.']); return; } unset($_SESSION['tfa_pending']); session_write_close(); [$plain, $hash] = tfa_new_recovery(); tfa_put(['enabled' => true, 'secret' => $secret, 'since' => time(), 'last' => $step, 'recovery' => $hash]); lkm_log('[2fa] ligado'); tfa_alert('login com 2 fatores LIGADO no painel.'); json_out(['ok' => true, 'recovery' => $plain]); return; case 'disable': case 'recovery': session_write_close(); if (!tfa_on()) { json_out(['ok' => false, 'error' => 'O 2FA não está ligado.']); return; } if (!tfa_check((string)($in['code'] ?? ''))) { sleep(2); json_out(['ok' => false, 'error' => 'Código errado. Use o código que aparece agora no app (ou um de recuperação).']); return; } if ($act === 'disable') { @unlink(tfa_file()); lkm_log('[2fa] desligado pelo painel'); tfa_alert('login com 2 fatores DESLIGADO no painel (de ' . client_ip() . '). Se não foi você, troque a senha agora.'); json_out(['ok' => true]); return; } [$plain, $hash] = tfa_new_recovery(); $d = tfa_get(); $d['recovery'] = $hash; tfa_put($d); lkm_log('[2fa] códigos de recuperação novos'); json_out(['ok' => true, 'recovery' => $plain]); return; } session_write_close(); json_out(['ok' => false, 'error' => 'Ação inválida'], 400); } /* ------------------------------------------------------------------ aba Internet (Cloudflare Tunnel) */ function cf_endpoint(): void { $act = (string)($_GET['cf'] ?? ''); $valid = hash_equals((string)($_SESSION['csrf'] ?? ''), (string)($_SERVER['HTTP_X_LKM_TOKEN'] ?? '')); session_write_close(); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !$valid) { json_out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 403); return; } if (!in_array($act, ['status', 'login', 'setup', 'route_add', 'route_del', 'toggle', 'quick_start', 'quick_stop', 'unblock'], true)) { json_out(['ok' => false, 'error' => 'Ação inválida'], 400); return; } $in = json_decode((string)file_get_contents('php://input'), true); if (!is_array($in)) $in = []; if ($act === 'unblock') { $ip = (string)($in['ip'] ?? ''); login_fails(function ($d) use ($ip) { unset($d[$ip]); return $d; }); lkm_log('[login] IP liberado: ' . $ip); json_out(['ok' => true]); return; } if (!is_file(LKM_LIB . '/cf.php')) { json_out(['ok' => false, 'error' => 'Atualize o LK Monitor (sudo lk, opção 33) para usar a aba Internet.']); return; } if ($act !== 'status' && shell_user() !== 'root') { json_out(['ok' => false, 'error' => 'A aba Internet precisa do terminal do LK Monitor como root. No servidor: sudo lk › opção 33 › instalar / atualizar e escolha root.']); return; } $in['action'] = $act; if ($act !== 'status') lkm_log('[internet] ' . $act . (isset($in['host']) ? ' ' . $in['host'] : '') . (isset($in['target']['type']) ? ' -> ' . $in['target']['type'] . ' ' . ($in['target']['name'] ?? $in['target']['port'] ?? '') : '')); @set_time_limit(0); $r = helper_exec('cf.php', $in, 120); if ($act === 'status') $r += login_report() + ['here' => (string)($_SERVER['HTTP_HOST'] ?? ''), 'via' => isset($_SERVER['HTTP_CF_RAY']) ? 'cloudflare' : 'rede']; json_out($r); } /* ------------------------------------------------------------------ coleta */ function take_sample(): array { $cpu = []; foreach (explode("\n", rf('/proc/stat')) as $l) { if (!preg_match('/^(cpu\d*)\s+(.*)$/', $l, $m)) continue; $v = array_map('intval', preg_split('/\s+/', trim($m[2]))); $cpu[$m[1]] = [array_sum(array_slice($v, 0, 8)), ($v[3] ?? 0) + ($v[4] ?? 0)]; } $net = []; foreach (explode("\n", rf('/proc/net/dev')) as $l) { if (!preg_match('/^\s*([^:\s]+):\s*(.*)$/', $l, $m)) continue; $if = $m[1]; if (skip_iface($if)) continue; $p = preg_split('/\s+/', trim($m[2])); $net[$if] = [(int)($p[0] ?? 0), (int)($p[8] ?? 0)]; } $proc = []; foreach (glob('/proc/[0-9]*', GLOB_ONLYDIR) ?: [] as $d) { $s = rf("$d/stat"); if ($s === '') continue; $a = strpos($s, '('); $b = strrpos($s, ')'); if ($a === false || $b === false) continue; $f = explode(' ', substr($s, $b + 2)); $proc[(int)basename($d)] = [(int)($f[11] ?? 0) + (int)($f[12] ?? 0), (int)($f[21] ?? 0), substr($s, $a + 1, $b - $a - 1)]; } return ['t' => microtime(true), 'cpu' => $cpu, 'net' => $net, 'proc' => $proc]; } function slim(array $s): array { return ['t' => $s['t'], 'cpu' => $s['cpu'], 'net' => $s['net'], 'proc' => array_map(fn($p) => $p[0], $s['proc'])]; } function users_map(): array { $u = []; foreach (explode("\n", rf('/etc/passwd')) as $l) { $p = explode(':', $l); if (count($p) > 2) $u[(int)$p[2]] = $p[0]; } return $u; } function battery(): ?array { $bat = null; $ac = null; foreach (glob('/sys/class/power_supply/*') ?: [] as $p) { $type = trim(rf("$p/type")); if ($type === 'Battery' && $bat === null && trim(rf("$p/scope")) !== 'Device') { $bat = ['pct' => (int)trim(rf("$p/capacity")), 'status' => trim(rf("$p/status"))]; } elseif ($type === 'Mains') { $ac = trim(rf("$p/online")) === '1'; } } if ($bat) $bat['ac'] = $ac; return $bat; } function services(): array { $c = ['apache2', 'nginx', 'mariadb', 'mysql', 'redis-server', 'supervisor', 'ssh', 'ufw', 'docker', 'fail2ban', 'cron', 'pm2-root', 'tailscaled']; foreach (glob('/etc/php/*/fpm', GLOB_ONLYDIR) ?: [] as $d) $c[] = 'php' . basename(dirname($d)) . '-fpm'; $have = []; foreach ($c as $s) { foreach (['/usr/lib/systemd/system', '/lib/systemd/system', '/etc/systemd/system'] as $dir) { if (is_file("$dir/$s.service")) { $have[$s] = 1; break; } } } if (isset($have['mariadb'])) unset($have['mysql']); $names = array_keys($have); if (!$names) return []; $st = explode("\n", trim(sh('systemctl is-active ' . implode(' ', array_map('escapeshellarg', $names))))); $out = []; foreach ($names as $i => $n) $out[] = ['name' => $n, 'state' => ($st[$i] ?? '') !== '' ? $st[$i] : 'unknown']; return $out; } function collect_stats(): array { $cur = take_sample(); $prev = $_SESSION['prev'] ?? null; if (!$prev || $cur['t'] - $prev['t'] > 20) { usleep(400000); $prev = slim($cur); $cur = take_sample(); } $_SESSION['prev'] = slim($cur); session_write_close(); $dt = max(0.1, $cur['t'] - $prev['t']); // CPU $pct = []; foreach ($cur['cpu'] as $k => [$tot, $idle]) { $p = $prev['cpu'][$k] ?? null; $dTot = $p ? $tot - $p[0] : 0; $pct[$k] = $dTot > 0 ? round(max(0, min(100, 100 * (1 - ($idle - $p[1]) / $dTot))), 1) : 0.0; } $cpuTotal = $pct['cpu'] ?? 0.0; unset($pct['cpu']); $cores = array_values($pct); $ncpu = max(1, count($cores)); // memória $mi = meminfo(); $memTotal = $mi['MemTotal'] ?? 0; $memAvail = $mi['MemAvailable'] ?? 0; // rede $ifs = []; $rx = 0.0; $tx = 0.0; foreach ($cur['net'] as $if => [$r, $t]) { if ($r + $t === 0) continue; $p = $prev['net'][$if] ?? [$r, $t]; $rr = max(0, ($r - $p[0]) / $dt); $tr = max(0, ($t - $p[1]) / $dt); $rx += $rr; $tx += $tr; $ifs[] = ['name' => $if, 'rx' => round($rr), 'tx' => round($tr), 'rx_total' => $r, 'tx_total' => $t]; } // processos $users = users_map(); $rows = []; foreach ($cur['proc'] as $pid => [$ticks, $rss, $comm]) { $pt = $prev['proc'][$pid] ?? null; $c = $pt === null ? 0.0 : max(0, ($ticks - $pt) / 100 / $dt * 100); $rows[] = [$pid, $comm, $c, $rss * 4096]; } $fmt = function (array $r) use ($users, $memTotal): array { $uid = @fileowner('/proc/' . $r[0]); return [ 'pid' => $r[0], 'name' => $r[1], 'user' => $uid === false ? '?' : ($users[$uid] ?? (string)$uid), 'cpu' => round($r[2], 1), 'mem' => $r[3], 'mem_pct' => $memTotal ? round($r[3] / $memTotal * 100, 1) : 0, ]; }; $byCpu = $rows; usort($byCpu, fn($a, $b) => $b[2] <=> $a[2] ?: $b[3] <=> $a[3]); $byMem = $rows; usort($byMem, fn($a, $b) => $b[3] <=> $a[3]); // info $os = 'Linux'; if (preg_match('/^PRETTY_NAME="?([^"\n]+)"?/m', rf('/etc/os-release'), $m)) $os = $m[1]; $ci = rf('/proc/cpuinfo'); $model = preg_match('/^model name\s*:\s*(.+)$/m', $ci, $m) ? trim($m[1]) : php_uname('m'); $mhz = preg_match_all('/^cpu MHz\s*:\s*([\d.]+)/m', $ci, $mm) ? round(array_sum($mm[1]) / count($mm[1])) : null; $load = array_map('floatval', array_slice(explode(' ', rf('/proc/loadavg')), 0, 3)); $ips = array_values(array_filter(explode(' ', trim(sh('hostname -I'))), fn($ip) => $ip !== '' && !str_contains($ip, ':'))); return [ 'time' => date('H:i:s'), 'host' => gethostname(), 'os' => $os, 'kernel' => php_uname('r'), 'uptime' => (int)(float)rf('/proc/uptime'), 'ips' => $ips, 'php' => PHP_VERSION, 'cpu' => ['total' => $cpuTotal, 'cores' => $cores, 'count' => $ncpu, 'model' => $model, 'mhz' => $mhz, 'load' => $load], 'mem' => [ 'total' => $memTotal, 'used' => $memTotal - $memAvail, 'avail' => $memAvail, 'cached' => ($mi['Cached'] ?? 0) + ($mi['Buffers'] ?? 0), 'swap_total' => $mi['SwapTotal'] ?? 0, 'swap_used' => ($mi['SwapTotal'] ?? 0) - ($mi['SwapFree'] ?? 0), ], 'net' => ['rx' => round($rx), 'tx' => round($tx), 'ifs' => $ifs, 'conn' => tcp_established()], 'disks' => disks(), 'temp' => temps(), 'battery' => battery(), 'services' => services(), 'procs' => [ 'count' => count($rows), 'cpu' => array_map($fmt, array_slice($byCpu, 0, 8)), 'mem' => array_map($fmt, array_slice($byMem, 0, 8)), ], ]; } /* ------------------------------------------------------------------ terminal */ function shell_user(): string { return defined('LKM_SHELL_USER') ? (string)LKM_SHELL_USER : ''; } function user_home(string $u): string { foreach (explode("\n", rf('/etc/passwd')) as $l) { $p = explode(':', $l); if (($p[0] ?? '') === $u && !empty($p[5])) return $p[5]; } return $u === 'root' ? '/root' : '/'; } function run_cmd_endpoint(): void { header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); $u = shell_user(); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !hash_equals((string)($_SESSION['csrf'] ?? ''), (string)($_SERVER['HTTP_X_LKM_TOKEN'] ?? ''))) { http_response_code(403); echo '{"error":"token"}'; return; } if ($u === '') { echo json_encode(['out' => "Terminal desativado na instalação do LK Monitor.\n", 'cwd' => '/', 'code' => 1]); return; } $in = json_decode((string)file_get_contents('php://input'), true); $cmd = trim((string)($in['cmd'] ?? '')); $home = user_home($u); $cwd = (string)($_SESSION['cwd'] ?? $home); session_write_close(); if ($cmd === '') { echo json_encode(['out' => '', 'cwd' => $cwd, 'code' => 0]); return; } if (strlen($cmd) > 4000) { echo json_encode(['out' => "Comando muito longo.\n", 'cwd' => $cwd, 'code' => 1]); return; } @file_put_contents(LKM_DATA . '/cmd.log', date('Y-m-d H:i:s') . ' ' . client_ip() . " [$u] " . str_replace("\n", ' ', $cmd) . "\n", FILE_APPEND | LOCK_EX); $script = 'exec 2>&1; cd -- "$1" 2>/dev/null || cd ~; eval "$2"; __lkm_rc=$?; printf "\n__LKM_PWD__%s\n" "$PWD"; exit $__lkm_rc'; $argv = ['/usr/bin/sudo', '-n', '-u', $u, '/usr/bin/env', 'HOME=' . $home, 'USER=' . $u, 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin', 'TERM=dumb', 'PAGER=cat', 'SYSTEMD_PAGER=', 'SYSTEMD_COLORS=0', 'COLUMNS=140', 'LANG=C.UTF-8', '/usr/bin/timeout', '-k', '3', (string)LKM_CMD_TIMEOUT, '/bin/bash', '-c', $script, 'lkm', $cwd, $cmd]; $out = ''; $code = 1; $proc = @proc_open($argv, [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($proc)) { $out = "Não foi possível executar o comando (proc_open bloqueado?).\n"; } else { $max = 300000; $cut = false; while (!feof($pipes[1])) { $chunk = fread($pipes[1], 65536); if ($chunk === false) break; if (strlen($out) < $max) $out .= $chunk; else $cut = true; } $err = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $code = proc_close($proc); if ($err !== '' && $err !== false) $out = $err . $out; if ($cut) $out .= "\n[saída cortada: muito grande]\n"; } $newCwd = $cwd; $pos = strrpos($out, "\n__LKM_PWD__"); if ($pos !== false) { $newCwd = trim(substr($out, $pos + 12)); $out = substr($out, 0, $pos); } if ($code === 124 || $code === 137) $out .= "\n[tempo esgotado: " . LKM_CMD_TIMEOUT . "s]"; if (str_contains($out, 'a password is required') || str_contains($out, 'password is required')) { $out .= "\n[sudo não configurado para o LK Monitor. Rode o instalador de novo.]"; } $out = preg_replace('/\e\[[0-9;?]*[ -\/]*[@-~]|\e\][^\a]*\a|\r(?!\n)/', '', $out); if (!mb_check_encoding($out, 'UTF-8')) $out = mb_convert_encoding($out, 'UTF-8', 'UTF-8'); session_start(); $_SESSION['cwd'] = $newCwd; session_write_close(); echo json_encode(['out' => $out, 'cwd' => $newCwd, 'code' => $code], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); } /* ------------------------------------------------------------------ terminal completo (xterm.js + serviço lkmonitor-term) */ /** * O navegador pede um ticket aqui (depois do login) e abre o WebSocket em term-ws?t=. * O serviço lkmonitor-term (lib/term.py, usuário lkmonitor) lê e apaga o ticket: uso único, vale 60 s. */ function term_endpoint(): void { $act = (string)($_GET['term'] ?? ''); $valid = hash_equals((string)($_SESSION['csrf'] ?? ''), (string)($_SERVER['HTTP_X_LKM_TOKEN'] ?? '')); session_write_close(); if (!$valid) { json_out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 403); return; } if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { json_out(['ok' => false, 'error' => 'Método inválido'], 405); return; } if (shell_user() === '') { json_out(['ok' => false, 'error' => 'Terminal desativado. Rode o instalador de novo e escolha root ou seu usuário.', 'off' => true]); return; } $port = (int)LKM_TERM_PORT; if ($port <= 0) { json_out(['ok' => false, 'error' => 'O terminal completo não está instalado. Atualize o LK Monitor (sudo lk, opção 33).', 'off' => true]); return; } $dir = LKM_DATA . '/term'; if ($act === 'list') { $d = json_decode(rf("$dir/sessions.json"), true); $list = []; foreach ((array)($d['sessions'] ?? []) as $s) { if (!is_array($s) || !preg_match('/^[a-f0-9]{32}$/', (string)($s['id'] ?? ''))) continue; $list[] = ['id' => $s['id'], 'created' => (int)($s['created'] ?? 0), 'attached' => !empty($s['attached'])]; } json_out(['ok' => true, 'sessions' => $list, 'ttl' => (int)($d['ttl'] ?? 0)]); return; } if ($act !== 'ticket') { json_out(['ok' => false, 'error' => 'Ação inválida'], 400); return; } $fp = @fsockopen('127.0.0.1', $port, $en, $es, 1.5); if (!$fp) { json_out(['ok' => false, 'error' => 'O serviço do terminal está parado. No servidor rode: sudo systemctl restart lkmonitor-term', 'down' => true]); return; } fclose($fp); $in = json_decode((string)file_get_contents('php://input'), true); if (!is_array($in)) $in = []; $sid = (string)($in['sid'] ?? ''); if (!preg_match('/^[a-f0-9]{32}$/', $sid)) $sid = ''; if (!is_dir($dir)) @mkdir($dir, 0700, true); foreach (glob("$dir/t-*.json") ?: [] as $f) { if ((int)@filemtime($f) < time() - 120) @unlink($f); } $t = bin2hex(random_bytes(16)); $f = "$dir/t-$t.json"; $data = ['exp' => time() + 60, 'sid' => $sid !== '' ? $sid : null, 'cols' => (int)($in['cols'] ?? 100), 'rows' => (int)($in['rows'] ?? 30)]; if (@file_put_contents($f, json_encode($data), LOCK_EX) === false) { json_out(['ok' => false, 'error' => "Não foi possível preparar o terminal (sem permissão em $dir)."]); return; } @chmod($f, 0600); json_out(['ok' => true, 'ticket' => $t]); } /* ------------------------------------------------------------------ gerenciador de arquivos */ function json_out(array $d, int $code = 200): void { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); echo json_encode($d, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); } /** Executa o helper como o usuário configurado. Com $stream=true envia o stdout direto ao navegador. */ function fm_exec(array $req, bool $stream = false): ?array { $argv = ['/usr/bin/sudo', '-n', '-u', shell_user(), LKM_PHP_CLI, '-d', 'memory_limit=1024M', '-d', 'display_errors=stderr', LKM_FM_HELPER]; $proc = @proc_open($argv, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($proc)) return ['ok' => false, 'error' => 'Não foi possível executar o gerenciador (proc_open bloqueado?).']; fwrite($pipes[0], json_encode($req, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); fclose($pipes[0]); if ($stream) { while (!feof($pipes[1])) { $chunk = fread($pipes[1], 262144); if ($chunk === false) break; if ($chunk === '') continue; echo $chunk; flush(); if (connection_aborted()) break; } fclose($pipes[1]); fclose($pipes[2]); proc_close($proc); return null; } $out = stream_get_contents($pipes[1]); $err = trim((string)stream_get_contents($pipes[2])); fclose($pipes[1]); fclose($pipes[2]); proc_close($proc); $d = json_decode((string)$out, true); if (is_array($d)) return $d; if (str_contains($err, 'password') || str_contains($err, 'sudo')) { return ['ok' => false, 'error' => 'Permissão do sudo não configurada para o painel. Rode o instalador de novo.']; } return ['ok' => false, 'error' => $err !== '' ? $err : 'Resposta inválida do gerenciador.']; } function fm_endpoint(): void { $act = (string)($_GET['fm'] ?? ''); $tok = (string)($_SERVER['HTTP_X_LKM_TOKEN'] ?? ($_GET['t'] ?? '')); $valid = hash_equals((string)($_SESSION['csrf'] ?? ''), $tok); session_write_close(); if (!$valid) { json_out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 403); return; } if (shell_user() === '') { json_out(['ok' => false, 'error' => 'Gerenciador desativado. Rode o instalador e escolha root ou seu usuário.']); return; } @set_time_limit(0); if ($act === 'download' || $act === 'view') { fm_download($act === 'view'); return; } if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { json_out(['ok' => false, 'error' => 'Método inválido'], 405); return; } if ($act === 'upload') { $f = $_FILES['file'] ?? null; if (!$f || ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { $code = (int)($f['error'] ?? UPLOAD_ERR_NO_FILE); $msg = [ UPLOAD_ERR_INI_SIZE => 'Arquivo maior que o limite do PHP (upload_max_filesize).', UPLOAD_ERR_FORM_SIZE => 'Arquivo maior que o limite permitido.', UPLOAD_ERR_PARTIAL => 'Envio interrompido. Tente de novo.', UPLOAD_ERR_NO_FILE => 'Nenhum arquivo recebido (pode ter passado do limite post_max_size).', UPLOAD_ERR_NO_TMP_DIR => 'Pasta temporária ausente no servidor.', UPLOAD_ERR_CANT_WRITE => 'Falha ao gravar o arquivo temporário (disco cheio?).', ][$code] ?? 'Falha no envio (código ' . $code . ').'; json_out(['ok' => false, 'error' => $msg]); return; } @chmod($f['tmp_name'], 0644); $r = fm_exec(['action' => 'upload', 'tmp' => $f['tmp_name'], 'dest' => (string)($_POST['dest'] ?? ''), 'name' => (string)($_POST['name'] ?? $f['name']), 'overwrite' => ($_POST['overwrite'] ?? '') === '1']); @unlink($f['tmp_name']); json_out($r ?? ['ok' => false]); return; } $allowed = ['list', 'stat', 'read', 'save', 'mkdir', 'newfile', 'rename', 'delete', 'copy', 'move', 'chmod', 'chown', 'zip', 'extract', 'search']; if (!in_array($act, $allowed, true)) { json_out(['ok' => false, 'error' => 'Ação inválida'], 400); return; } $in = json_decode((string)file_get_contents('php://input'), true); if (!is_array($in)) $in = []; $in['action'] = $act; if (in_array($act, ['save', 'delete', 'move', 'rename', 'chmod', 'chown', 'extract'], true)) { $what = $in['path'] ?? implode(', ', array_slice((array)($in['paths'] ?? []), 0, 5)); @file_put_contents(LKM_DATA . '/cmd.log', date('Y-m-d H:i:s') . ' ' . client_ip() . ' [arquivos] ' . $act . ' ' . $what . "\n", FILE_APPEND | LOCK_EX); } json_out(fm_exec($in) ?? ['ok' => false]); } function fm_download(bool $inline): void { $path = (string)($_GET['path'] ?? ''); if (isset($_GET['paths'])) { $paths = json_decode((string)$_GET['paths'], true); if (!is_array($paths) || !$paths) { http_response_code(400); echo 'Nada selecionado'; return; } $st = ['ok' => true, 'is_dir' => true]; } else { $st = fm_exec(['action' => 'stat', 'path' => $path]); } if (empty($st['ok'])) { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo $st['error'] ?? 'Arquivo não encontrado'; return; } while (ob_get_level()) ob_end_clean(); header('X-Content-Type-Options: nosniff'); header("Content-Security-Policy: default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; sandbox"); header('Cache-Control: private, no-store'); if (!empty($st['is_dir'])) { $z = fm_exec(isset($paths) ? ['action' => 'zipdl', 'paths' => $paths, 'tmpdir' => LKM_DATA . '/tmp'] : ['action' => 'zipdl', 'path' => $path, 'tmpdir' => LKM_DATA . '/tmp']); if (empty($z['ok']) || !is_file((string)$z['file'])) { http_response_code(500); header('Content-Type: text/plain; charset=utf-8'); echo $z['error'] ?? 'Falha ao compactar a pasta'; return; } header('Content-Type: application/zip'); header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode((string)$z['name'])); header('Content-Length: ' . filesize($z['file'])); readfile($z['file']); @unlink($z['file']); return; } $mime = (string)($st['mime'] ?? 'application/octet-stream'); $safe = preg_match('#^(image/(png|jpeg|gif|webp|svg\+xml|x-icon|bmp|avif)|video/(mp4|webm)|audio/(mpeg|ogg|wav)|application/pdf|text/plain)$#', $mime); header('Content-Type: ' . ($inline && $safe ? $mime : 'application/octet-stream')); header('Content-Disposition: ' . ($inline && $safe ? 'inline' : 'attachment') . "; filename*=UTF-8''" . rawurlencode((string)$st['name'])); if (isset($st['size'])) header('Content-Length: ' . (int)$st['size']); fm_exec(['action' => 'cat', 'path' => $path], true); } /* ------------------------------------------------------------------ ações do sistema (serviços, processos, modo servidor, energia) */ function sys_endpoint(): void { $act = (string)($_GET['sys'] ?? ''); $valid = hash_equals((string)($_SESSION['csrf'] ?? ''), (string)($_SERVER['HTTP_X_LKM_TOKEN'] ?? '')); session_write_close(); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !$valid) { json_out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 403); return; } if (!in_array($act, ['status', 'service', 'kill', 'mode', 'power'], true)) { json_out(['ok' => false, 'error' => 'Ação inválida'], 400); return; } if (!is_file(LKM_LIB . '/sys.php')) { json_out(['ok' => false, 'error' => 'Atualize o LK Monitor (sudo lk, opção 33) para usar estes botões.']); return; } if ($act !== 'status' && shell_user() !== 'root') { json_out(['ok' => false, 'error' => 'Essa ação precisa do terminal do LK Monitor como root. No servidor: sudo lk › opção 33 › instalar / atualizar e escolha root.']); return; } $in = json_decode((string)file_get_contents('php://input'), true); if (!is_array($in)) $in = []; $in['action'] = $act; if ($act !== 'status') { $what = ['service' => ($in['op'] ?? '') . ' ' . ($in['name'] ?? ''), 'kill' => ($in['sig'] ?? 'TERM') . ' ' . ($in['pid'] ?? '') . ' ' . ($in['name'] ?? ''), 'mode' => ($in['to'] ?? '') . ' ' . ($in['when'] ?? ''), 'power' => (string)($in['op'] ?? '')][$act]; lkm_log('[sistema] ' . $act . ' ' . $what); } @set_time_limit(0); json_out(helper_exec('sys.php', $in, $act === 'mode' ? 620 : 150)); } /* ------------------------------------------------------------------ App Store */ function apps_endpoint(): void { $act = (string)($_GET['apps'] ?? ''); $valid = hash_equals((string)($_SESSION['csrf'] ?? ''), (string)($_SERVER['HTTP_X_LKM_TOKEN'] ?? '')); session_write_close(); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !$valid) { json_out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 403); return; } if (!in_array($act, ['list', 'install', 'remove', 'svc'], true)) { json_out(['ok' => false, 'error' => 'Ação inválida'], 400); return; } if (!is_file(LKM_LIB . '/apps.php')) { json_out(['ok' => false, 'error' => 'Atualize o LK Monitor (sudo lk, opção 33) para usar a App Store.']); return; } if ($act !== 'list' && shell_user() !== 'root') { json_out(['ok' => false, 'error' => 'A App Store precisa do terminal do LK Monitor como root. No servidor: sudo lk › opção 33 › instalar / atualizar e escolha root.']); return; } $in = json_decode((string)file_get_contents('php://input'), true); if (!is_array($in)) $in = []; $in['action'] = $act; if ($act !== 'list') lkm_log('[apps] ' . $act . ' ' . ($in['id'] ?? '') . ($act === 'svc' ? ' ' . ($in['op'] ?? '') : '')); @set_time_limit(0); $r = helper_exec('apps.php', $in, $act === 'svc' ? 150 : 60); if ($act === 'list' && !empty($r['apps'])) { // fotos dos apps: appicons/.svg|png|webp|jpg foreach ($r['apps'] as &$a) { foreach (['svg', 'png', 'webp', 'jpg'] as $ext) { $f = __DIR__ . '/appicons/' . $a['id'] . '.' . $ext; if (is_file($f)) { $a['icon'] = 'appicons/' . $a['id'] . '.' . $ext . '?v=' . filemtime($f); break; } } } unset($a); } json_out($r); } /* ------------------------------------------------------------------ sites */ function lkm_log(string $what): void { @file_put_contents(LKM_DATA . '/cmd.log', date('Y-m-d H:i:s') . ' ' . client_ip() . ' ' . str_replace("\n", ' ', $what) . "\n", FILE_APPEND | LOCK_EX); } /** Roda um helper de LKM_LIB com JSON no stdin: via sudo como o usuário do painel ou, sem terminal, como lkmonitor (somente leitura). */ function helper_exec(string $file, array $req, int $timeout = 180): array { $u = shell_user(); $cmd = ['/usr/bin/timeout', '-k', '5', (string)$timeout, LKM_PHP_CLI, '-d', 'display_errors=stderr', LKM_LIB . '/' . $file]; if ($u !== '') $cmd = array_merge(['/usr/bin/sudo', '-n', '-u', $u], $cmd); $proc = @proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($proc)) return ['ok' => false, 'error' => 'Não foi possível executar o helper (proc_open bloqueado?).']; fwrite($pipes[0], json_encode($req, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); fclose($pipes[0]); $out = stream_get_contents($pipes[1]); $err = trim((string)stream_get_contents($pipes[2])); fclose($pipes[1]); fclose($pipes[2]); $code = proc_close($proc); $d = json_decode((string)$out, true); if (is_array($d)) return $d; if ($code === 124 || $code === 137) return ['ok' => false, 'error' => 'A operação demorou demais e foi cancelada.']; if (str_contains($err, 'password') || str_contains($err, 'sudo')) { return ['ok' => false, 'error' => 'Permissão do sudo não configurada para o painel. Rode o instalador de novo.']; } return ['ok' => false, 'error' => $err !== '' ? $err : 'Resposta inválida do helper.']; } function sites_endpoint(): void { $act = (string)($_GET['sites'] ?? ''); $valid = hash_equals((string)($_SESSION['csrf'] ?? ''), (string)($_SERVER['HTTP_X_LKM_TOKEN'] ?? '')); session_write_close(); if (!$valid) { json_out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 403); return; } if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { json_out(['ok' => false, 'error' => 'Método inválido'], 405); return; } $read = ['list', 'size', 'logs', 'deploy_info', 'backups', 'backup_cfg']; $write = ['toggle', 'create', 'remove', 'deploy', 'backup', 'backup_delete', 'restore', 'backup_cfg_set']; if (!in_array($act, array_merge($read, $write), true)) { json_out(['ok' => false, 'error' => 'Ação inválida'], 400); return; } $in = json_decode((string)file_get_contents('php://input'), true); if (!is_array($in)) $in = []; $in['action'] = $act; if (in_array($act, $write, true)) { if (shell_user() !== 'root') { json_out(['ok' => false, 'error' => 'Sites em modo somente leitura: o painel não tem acesso root.']); return; } $name = (string)($in['name'] ?? $in['domain'] ?? ''); $what = match ($act) { 'toggle' => (!empty($in['on']) ? 'ligar ' : 'desligar ') . $name, 'create' => 'criar ' . $name . ' (' . ($in['type'] ?? '') . (!empty($in['db']) ? ', com banco' : '') . ')', 'remove' => 'remover ' . $name . (!empty($in['files']) ? ' e apagar os arquivos' : ''), 'deploy' => 'deploy ' . $name . ' (' . implode(', ', array_keys(array_filter((array)($in['opts'] ?? [])))) . ')', 'backup' => 'backup ' . $name, 'backup_delete' => 'apagar backup ' . $name . '/' . ($in['file'] ?? ''), 'restore' => 'restaurar ' . $name . ' a partir de ' . ($in['file'] ?? ''), 'backup_cfg_set' => 'backup automático: ' . (!empty($in['cfg']['enabled']) ? 'ativo às ' . ($in['cfg']['time'] ?? '') . ', manter ' . ($in['cfg']['keep'] ?? '') . ', sites ' . implode(', ', (array)($in['cfg']['sites'] ?? [])) : 'desativado'), }; lkm_log('[sites] ' . $what); } @set_time_limit(0); json_out(helper_exec('sites.php', $in)); } /* ------------------------------------------------------------------ configurações (alertas e canais) */ function cfg_endpoint(): void { $act = (string)($_GET['cfg'] ?? ''); $valid = hash_equals((string)($_SESSION['csrf'] ?? ''), (string)($_SERVER['HTTP_X_LKM_TOKEN'] ?? '')); session_write_close(); require_once LKM_LIB . '/alerts.inc.php'; if ($act === 'get') { json_out(['ok' => true, 'settings' => al_public(al_settings()), 'history' => al_history(), 'active' => al_active(), 'host' => (string)gethostname(), 'services' => array_keys(al_services()), 'sites' => al_sites(), 'curl' => function_exists('curl_init'), 'gowa' => al_gowa_public()]); return; } if ($act === 'wa_status') { json_out(['ok' => true, 'gowa' => al_gowa_public(), 'st' => al_gowa_status()]); return; } if (!$valid) { json_out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 403); return; } if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { json_out(['ok' => false, 'error' => 'Método inválido'], 405); return; } $in = json_decode((string)file_get_contents('php://input'), true); if (!is_array($in)) $in = []; $new = al_merge((array)($in['settings'] ?? []), al_settings()); if ($act === 'save') { if ($err = al_validate($new)) { json_out(['ok' => false, 'error' => $err]); return; } al_write('settings.json', $new); $on = array_keys(array_filter($new['rules'], fn($r) => $r['on'])); lkm_log('[config] alertas salvos (regras: ' . (implode(', ', $on) ?: 'nenhuma') . '; telegram ' . ($new['telegram']['on'] ? 'ligado' : 'desligado') . '; whatsapp ' . ($new['whatsapp']['on'] ? 'ligado' : 'desligado') . ')'); json_out(['ok' => true, 'settings' => al_public($new)]); return; } if ($act === 'test') { $ch = (string)($in['channel'] ?? ''); if (!in_array($ch, ['telegram', 'whatsapp'], true)) { json_out(['ok' => false, 'error' => 'Canal inválido'], 400); return; } $new[$ch]['on'] = true; if ($err = al_validate($new)) { json_out(['ok' => false, 'error' => $err]); return; } lkm_log('[config] envio de teste: ' . $ch); $r = al_send('✅ [' . gethostname() . '] Teste do LK Monitor: este canal de alertas está funcionando.', $new, [$ch]); json_out($r[$ch] === true ? ['ok' => true] : ['ok' => false, 'error' => (string)$r[$ch]]); return; } if ($act === 'clear') { al_write('alerts.json', []); lkm_log('[config] histórico de alertas apagado'); json_out(['ok' => true]); return; } json_out(['ok' => false, 'error' => 'Ação inválida'], 400); } /* ------------------------------------------------------------------ bancos de dados */ function db_endpoint(): void { $act = (string)($_GET['db'] ?? ''); $valid = hash_equals((string)($_SESSION['csrf'] ?? ''), (string)($_SERVER['HTTP_X_LKM_TOKEN'] ?? '')); session_write_close(); if (!$valid) { json_out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 403); return; } if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { json_out(['ok' => false, 'error' => 'Método inválido'], 405); return; } if (!in_array($act, ['overview', 'create', 'drop', 'password', 'tables', 'columns', 'rows', 'query', 'export'], true)) { json_out(['ok' => false, 'error' => 'Ação inválida'], 400); return; } if (shell_user() !== 'root') { json_out(['ok' => false, 'error' => 'A aba Bancos precisa de acesso root no painel.', 'noroot' => true]); return; } $in = json_decode((string)file_get_contents('php://input'), true); if (!is_array($in)) $in = []; $in['action'] = $act; $src = $in['src']['db'] ?? $in['src']['path'] ?? ''; match ($act) { 'create' => lkm_log('[bancos] criar ' . ($in['name'] ?? '')), 'drop' => lkm_log('[bancos] apagar ' . ($in['name'] ?? '') . ' (com backup)' . (!empty($in['users']) ? ' e usuário(s) ' . implode(', ', (array)$in['users']) : '')), 'password' => lkm_log('[bancos] trocar senha de ' . ($in['user'] ?? '') . (!empty($in['sites']) ? ' e atualizar .env de ' . implode(', ', (array)$in['sites']) : '')), 'query' => !empty($in['write']) ? lkm_log('[bancos] alteração em ' . $src . ': ' . mb_substr(preg_replace('/\s+/', ' ', (string)($in['sql'] ?? '')), 0, 400)) : null, default => null, }; @set_time_limit(0); $r = helper_exec('db.php', $in, $act === 'drop' ? 1900 : ($act === 'export' ? 180 : 90)); if ($act !== 'export' || empty($r['ok'])) { json_out($r); return; } $name = preg_replace('/[^A-Za-z0-9_.-]+/', '_', (string)($in['table'] ?? '') ?: 'consulta') . '-' . date('Ymd-His') . '.csv'; header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $name . '"'); header('Cache-Control: no-store'); header('X-LKM-More: ' . (!empty($r['more']) ? '1' : '0')); $o = fopen('php://output', 'wb'); fwrite($o, "\xEF\xBB\xBF"); fputcsv($o, $r['columns'], ';', '"', ''); foreach ($r['rows'] as $row) fputcsv($o, array_map(fn($v) => $v === null ? '' : (string)$v, $row), ';', '"', ''); fclose($o); } /* ------------------------------------------------------------------ tarefas em segundo plano (somente leitura) */ function job_read(string $id): ?array { if (!preg_match('/^\d{8}-\d{6}-[a-f0-9]{6}$/', $id)) return null; $J = json_decode(rf(LKM_DATA . "/jobs/$id.json"), true); if (!is_array($J)) return null; // tarefa que parou sem terminar (servidor reiniciado, processo morto) if ((($J['state'] ?? '') === 'running' && !file_exists('/proc/' . (int)($J['pid'] ?? 0))) || (($J['state'] ?? '') === 'queued' && time() - (int)($J['created'] ?? 0) > 120)) { $J['state'] = 'interrupted'; } unset($J['pid']); return $J; } function jobs_endpoint(): void { $act = (string)$_GET['jobs']; if ($act === 'list') { $site = (string)($_GET['site'] ?? ''); $type = (string)($_GET['type'] ?? ''); $files = glob(LKM_DATA . '/jobs/*.json') ?: []; rsort($files); $out = []; foreach ($files as $f) { $J = job_read(basename($f, '.json')); if (!$J || ($site !== '' && ($J['site'] ?? '') !== $site) || ($type !== '' && $J['type'] !== $type)) continue; $out[] = $J; if (count($out) >= 15) break; } json_out(['ok' => true, 'jobs' => $out]); return; } $id = (string)($_GET['id'] ?? ''); $J = job_read($id); if (!$J) { json_out(['ok' => false, 'error' => 'Tarefa não encontrada'], 404); return; } $f = LKM_DATA . "/jobs/$id.log"; if ($act === 'log') { header('Content-Type: text/plain; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $J['type'] . '-' . $id . '.log"'); header('Cache-Control: no-store'); readfile($f); return; } $size = (int)@filesize($f); $from = max(0, min($size, (int)($_GET['from'] ?? 0))); $max = 262144; $chunk = ''; if ($from < $size) { if ($size - $from > $max) $from = $size - $max; $h = @fopen($f, 'rb'); if ($h) { fseek($h, $from); $chunk = (string)fread($h, $size - $from); fclose($h); } } json_out(['ok' => true, 'job' => $J, 'from' => $from, 'size' => $from + strlen($chunk), 'chunk' => $chunk]); } function ca_download(): void { $f = LKM_DATA . '/ca/ca.crt'; if (!is_readable($f)) { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo 'Certificado não encontrado. Rode o instalador do LK Monitor (sudo lk, opção 33).'; return; } header('Content-Type: application/x-x509-ca-cert'); header('Content-Disposition: attachment; filename="lkmonitor-ca.crt"'); header('Cache-Control: no-store'); header('Content-Length: ' . filesize($f)); readfile($f); } /* ------------------------------------------------------------------ páginas */ function base_css(): string { return <<<'CSS' :root{--bg:#eef0f3;--bg2:#f7f8fa;--card:#ffffff;--line:#e3e6ea;--line2:#d3d8de;--tx:#333a42;--mut:#6f7884;--dim:#9aa3ad; --cpu:#2f8cdc;--mem:#8a63d2;--disk:#16a37a;--temp:#f08c00;--rx:#0ea5c6;--tx2:#e0559b;--ok:#20a53a;--warn:#e69a00;--bad:#e8483b;--pri:#20a53a; --r:3px;color-scheme:light;--mono:ui-monospace,"JetBrains Mono","SFMono-Regular",Menlo,Consolas,monospace} *{box-sizing:border-box;margin:0;padding:0} html,body{background:var(--bg);color:var(--tx);font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,sans-serif} body{min-height:100vh} .num{font-family:var(--mono);font-variant-numeric:tabular-nums} CSS; } /** Tags do app instalável (PWA). O service worker só registra em contexto seguro (HTTPS ou localhost). */ function pwa_head(): string { $title = htmlspecialchars(LKM_TITLE); return '' . '' . '' . '' . ""; } function page_login(string $err, bool $code = false): void { $title = htmlspecialchars(LKM_TITLE); $host = htmlspecialchars((string)gethostname()); $err = htmlspecialchars($err); $css = base_css(); $pwa = pwa_head(); $field = $code ? '

Digite o código de 6 dígitos do app de autenticação.

' . '' . '' . '
Perdeu o celular? Digite um código de recuperação no lugar (tipo abcde-fghij).
Voltar e usar outra senha
' : '' . ''; echo << {$title} · Login{$pwa}

{$host}

{$field}
{$err}
HTML; } function page_dashboard(): void { $title = htmlspecialchars(LKM_TITLE); $ver = LKM_VERSION; $u = shell_user(); $pwdone = $_SESSION['pwdone'] ?? null; if ($pwdone) unset($_SESSION['pwdone']); $cfg = json_encode([ 'token' => $_SESSION['csrf'] ?? '', 'user' => $u, 'pwdone' => $pwdone, 'host' => (string)gethostname(), 'cwd' => (string)($_SESSION['cwd'] ?? ($u !== '' ? user_home($u) : '/')), 'https' => (int)LKM_HTTPS_PORT, 'ca' => is_readable(LKM_DATA . '/ca/ca.crt'), 'ver' => LKM_VERSION, 'term' => (int)LKM_TERM_PORT > 0, 'ips' => array_values(array_filter(explode(' ', trim(sh('hostname -I'))), fn($ip) => $ip !== '' && !str_contains($ip, ':') && !str_starts_with($ip, '172.17.') && !str_starts_with($ip, '192.168.122.'))), ], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES); $css = base_css(); $pwa = pwa_head(); echo << {$title}{$pwa}
Painel
carregando...
conectando

Status

Carga
––
CPU
load 1 · 5 · 15 min
Memória
– /
Disco /
–
Temperatura
–
Carregando…

Serviços

Gráficos

CPU e memória

CPU 0%RAM 0%

Tráfego

↓ 0↑ 0

Temperatura

CPU –

Processos

ProcessoUsuárioCPUMemória

Sistema

Discos

Interfaces de rede

Interface↓ agora↑ agora↓ total↑ total

Sensores

Terminal modo simples

Comandos interativos (nano, top, htop, o menu do lk) não funcionam aqui. Use as versões diretas: top -bn1 | head -20, lk status. ↑/↓ histórico · Ctrl+L limpa · limite de 30s por comando.
HTML; } LKM_EOF_INDEX cat > "$DIR/fm.js" <<'LKM_EOF_FMJS' /* LK Monitor - gerenciador de arquivos */ (function () { 'use strict'; /* ------------------------------------------------------------ utilitários */ const C0 = typeof CFG !== 'undefined' ? CFG : {}; const TOKEN = C0.token || ''; const USER = C0.user || ''; const HOME = USER === 'root' ? '/root' : USER ? '/home/' + USER : '/'; const coarse = matchMedia('(pointer:coarse)').matches; const E = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const qs = (s, r) => (r || document).querySelector(s); const qsa = (s, r) => Array.from((r || document).querySelectorAll(s)); const ls = { get(k, d) { try { const v = localStorage.getItem('lkm.fm.' + k); return v == null ? d : JSON.parse(v); } catch (e) { return d; } }, set(k, v) { try { localStorage.setItem('lkm.fm.' + k, JSON.stringify(v)); } catch (e) { } } }; function fsize(b) { if (b == null) return '—'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; b = +b; while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } return (i ? b.toFixed(b >= 100 ? 0 : 1) : Math.round(b)) + ' ' + u[i]; } function fdate(t) { if (!t) return '—'; const d = new Date(t * 1000), p = n => String(n).padStart(2, '0'); return p(d.getDate()) + '/' + p(d.getMonth() + 1) + '/' + d.getFullYear() + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } function rwx(perm) { const m = parseInt(perm, 8) || 0, s = 'rwxrwxrwx'; let o = ''; for (let i = 0; i < 9; i++) o += (m & (1 << (8 - i))) ? s[i] : '-'; return o; } const dirname = p => { const i = p.replace(/\/+$/, '').lastIndexOf('/'); return i <= 0 ? '/' : p.slice(0, i); }; const basename = p => p.replace(/\/+$/, '').split('/').pop() || '/'; const pjoin = (d, n) => (d === '/' ? '' : d.replace(/\/+$/, '')) + '/' + n; const extOf = n => { const m = /\.([^.\/]+)$/.exec(n); return m && m.index > 0 ? m[1].toLowerCase() : ''; }; const shq = s => "'" + String(s).replace(/'/g, "'\\''") + "'"; function copyText(t) { if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(t); const ta = document.createElement('textarea'); ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) { } ta.remove(); return Promise.resolve(); } /* ------------------------------------------------------------ ícones */ const IC = { back: '', fwd: '', up: '', refresh: '', folderPlus: '', filePlus: '', upload: '', download: '', copy: '', cut: '', paste: '', rename: '', lock: '', zip: '', unzip: '', trash: '', search: '', grid: '', list: '', eye: '', eyeOff: '', info: '', open: '', edit: '', term: '', link: '', selAll: '', x: '', save: '', wrap: '', home: '', globe: '', server: '', cog: '', log: '', tmp: '', users: '', folderUp: '', chevL: '', chevR: '', sortU: '', sortD: '', phone: '', power: '', plus: '', more: '', folder: '', db: '', check: '', warn: '', pulse: '', rocket: '', play: '', table: '', key: '', archive: '', bell: '', send: '' }; const svg = (k, cls) => '' + (IC[k] || '') + ''; const T = { code: ['php', 'phtml', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'vue', 'py', 'rb', 'go', 'java', 'c', 'h', 'cpp', 'hpp', 'cs', 'rs', 'lua', 'kt', 'swift', 'pl', 'dart', 'scala', 'r'], web: ['html', 'htm', 'twig', 'tpl', 'blade', 'xml', 'xhtml'], style: ['css', 'scss', 'sass', 'less'], data: ['json', 'yml', 'yaml', 'toml', 'csv', 'tsv', 'sql', 'db', 'sqlite', 'ndjson'], conf: ['conf', 'cnf', 'cfg', 'ini', 'env', 'htaccess', 'properties', 'service', 'timer', 'socket', 'lock', 'gitignore', 'editorconfig', 'npmrc'], shell: ['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1'], text: ['txt', 'md', 'markdown', 'log', 'rst', 'srt', 'vtt', 'nfo', 'readme'], img: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp', 'avif', 'tif', 'tiff', 'psd'], zip: ['zip', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz', '7z', 'rar', 'zst'], pdf: ['pdf'], doc: ['doc', 'docx', 'odt', 'rtf', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp'], audio: ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus'], video: ['mp4', 'webm', 'mkv', 'avi', 'mov', 'wmv', 'm4v'], font: ['ttf', 'otf', 'woff', 'woff2', 'eot'], key: ['pem', 'key', 'crt', 'cer', 'csr', 'pub', 'p12', 'pfx', 'gpg'], bin: ['exe', 'deb', 'rpm', 'apk', 'iso', 'img', 'bin', 'so', 'dll', 'appimage', 'jar', 'phar'] }; const EXTCOL = { php: '#8892f6', js: '#e8c547', mjs: '#e8c547', cjs: '#e8c547', ts: '#3b8eea', tsx: '#3b8eea', jsx: '#5ccfe6', vue: '#41b883', py: '#4f8fd8', go: '#29beb0', rb: '#e0525b', java: '#e8873a', rs: '#de8a5a', html: '#f06a2b', htm: '#f06a2b', css: '#3fa9f5', scss: '#d6609c', json: '#d9b23a', sql: '#e86aa8', md: '#8b9bb8', yml: '#d8545a', yaml: '#d8545a' }; const CATCOL = { code: '#8892f6', web: '#f06a2b', style: '#3fa9f5', data: '#d9b23a', conf: '#8b9bb8', shell: '#3ccf7e', text: '#8b9bb8', img: '#b57cf2', zip: '#e3a92f', pdf: '#e5484d', doc: '#3f7fe0', audio: '#e8579b', video: '#f2555a', font: '#22b3a6', key: '#f0a33a', bin: '#38a3d9' }; const catOf = n => { const e = extOf(n); for (const k in T) if (T[k].includes(e)) return k; return ''; }; const TEXTISH = new Set([].concat(T.code, T.web, T.style, T.conf, T.shell, T.text, ['json', 'yml', 'yaml', 'toml', 'csv', 'tsv', 'sql', 'svg', 'ndjson', 'xml'])); function isText(it) { const e = extOf(it.name); if (TEXTISH.has(e)) return true; if (!e) return (it.size || 0) < 2 * 1024 * 1024; return /^(dockerfile|makefile|vagrantfile|procfile|license|readme|authors|changelog)$/i.test(it.name); } const isImg = it => ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp', 'avif'].includes(extOf(it.name)); const isArchive = it => /\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i.test(it.name); const isMedia = it => ['pdf', 'mp4', 'webm', 'mp3', 'ogg', 'wav'].includes(extOf(it.name)); function fileIcon(it, big) { if (it.type === 'dir') { const badge = it.link ? '' : ''; return '' + badge + ''; } const e = extOf(it.name), cat = catOf(it.name); const col = it.broken ? '#e5484d' : EXTCOL[e] || CATCOL[cat] || '#5d6b87'; const lab = big ? E((e || (it.name[0] === '.' ? it.name.slice(1) : '')).slice(0, 4).toUpperCase()) : ''; const badge = it.link ? '' : ''; return '' + (big ? '' + lab + '' : '') + badge + ''; } /* ------------------------------------------------------------ API */ async function api(act, body) { let r; try { r = await fetch('?fm=' + act, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-LKM-Token': TOKEN }, body: JSON.stringify(body || {}) }); } catch (e) { throw new Error('Sem conexão com o painel'); } if (r.status === 401) { location.reload(); throw new Error('Sessão expirada'); } let d; try { d = await r.json(); } catch (e) { throw new Error('Resposta inválida do servidor (' + r.status + ')'); } if (!d.ok) throw new Error(d.error || 'Erro desconhecido'); return d; } const dlUrl = (p, view) => '?fm=' + (view ? 'view' : 'download') + '&t=' + encodeURIComponent(TOKEN) + '&path=' + encodeURIComponent(p); /* ------------------------------------------------------------ estado */ const S = { path: ls.get('path', '/var/www'), items: [], view: [], sel: new Set(), anchor: null, sort: ls.get('sort', { k: 'name', d: 1 }), mode: ls.get('mode', 'list'), hidden: ls.get('hidden', true), filter: '', back: [], fwd: [], clip: null, search: null, writable: true, free: 0, total: 0, loaded: false, busy: false, error: '' }; let root, body, inited = false; /* ------------------------------------------------------------ notificações */ function toast(msg, kind) { let box = qs('.fm-toasts'); if (!box) { box = document.createElement('div'); box.className = 'fm-toasts'; document.body.appendChild(box); } const t = document.createElement('div'); t.className = 'fm-toast ' + (kind || 'ok'); t.textContent = msg; box.appendChild(t); setTimeout(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '0'; setTimeout(() => t.remove(), 320); }, kind === 'er' ? 6000 : 3200); } const fail = e => toast(e && e.message ? e.message : String(e), 'er'); /* ------------------------------------------------------------ janelas */ function dialog(o) { return new Promise(res => { const ov = document.createElement('div'); ov.className = 'fm-ov'; ov.innerHTML = '

' + (o.icon ? svg(o.icon) : '') + E(o.title) + '

' + (o.html || '') + '
'; const df = qs('.df', ov); let done = false; const close = v => { if (done) return; done = true; ov.remove(); document.removeEventListener('keydown', key, true); res(v); }; (o.buttons || [{ label: 'OK', value: true, kind: 'pri' }]).forEach(b => { const btn = document.createElement('button'); btn.textContent = b.label; if (b.kind) btn.className = b.kind; btn.onclick = async () => { if (b.value === undefined || b.value === false || b.value === null || !o.validate) return close(b.value); const ok = await o.validate(ov, b.value); if (ok !== false) close(typeof ok === 'object' && ok !== null ? ok : b.value); }; df.appendChild(btn); }); const key = e => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close(null); } else if (e.key === 'Enter' && !e.shiftKey && e.target.tagName !== 'TEXTAREA' && e.target.tagName !== 'BUTTON') { const pri = qs('.df button.pri, .df button.danger', ov); if (pri) { e.preventDefault(); e.stopPropagation(); pri.click(); } } }; document.addEventListener('keydown', key, true); ov.addEventListener('mousedown', e => { if (e.target === ov) close(null); }); document.body.appendChild(ov); if (o.onOpen) o.onOpen(ov, close); const f = qs('input:not([type=checkbox]),textarea', ov) || qs('.df button.pri,.df button.danger', ov); if (f) setTimeout(() => f.focus(), 30); }); } function prompt(title, label, value, icon) { return dialog({ title, icon, html: '
' + E(label) + '
', buttons: [{ label: 'Cancelar', value: null }, { label: 'OK', value: 'ok', kind: 'pri' }], onOpen(ov) { const i = qs('input', ov); setTimeout(() => { i.focus(); const dot = i.value.lastIndexOf('.'); i.setSelectionRange(0, dot > 0 ? dot : i.value.length); }, 40); }, validate(ov) { const v = qs('input', ov).value.trim(); if (!v) return false; return { v }; } }).then(r => r && r.v); } function confirmDlg(title, html, okLabel, danger) { return dialog({ title, html, icon: danger ? 'trash' : 'info', buttons: [{ label: 'Cancelar', value: null }, { label: okLabel || 'OK', value: true, kind: danger ? 'danger' : 'pri' }] }); } const namesHtml = list => '
' + list.slice(0, 12).map(p => '
' + E(basename(p)) + '
').join('') + (list.length > 12 ? '
… e mais ' + (list.length - 12) + '
' : '') + '
'; /* ------------------------------------------------------------ montagem */ const PLACES = [ { p: HOME, l: 'Início', i: 'home' }, { p: '/var/www', l: 'Sites', i: 'globe' }, { p: '/', l: 'Raiz', i: 'server' }, { p: '/etc', l: 'Configurações', i: 'cog' }, { p: '/var/log', l: 'Logs', i: 'log' }, { p: '/home', l: 'Usuários', i: 'users' }, { p: '/tmp', l: 'Temporários', i: 'tmp' } ]; function build() { root = qs('#fm'); root.innerHTML = '
' + '' + '
' + '
' + '' + '' + '' + '
' + '
' + '
' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
' + '
' + '
' + '
' + '
' + svg('upload') + 'Solte para enviar
' + '
' + ''; body = qs('#fmBody'); bind(); } /* ------------------------------------------------------------ navegação e dados */ async function load(path, opts) { opts = opts || {}; const target = path || S.path; setBusy(true); try { const d = await api('list', { path: target }); if (!opts.keepHistory && d.path !== S.path && S.loaded) { S.back.push(S.path); if (S.back.length > 60) S.back.shift(); S.fwd = []; } const changed = d.path !== S.path; S.path = d.path; S.items = d.items; S.writable = d.writable; S.free = d.free; S.total = d.total; S.error = ''; S.loaded = true; if (changed) { S.sel.clear(); S.anchor = null; S.filter = ''; const si = qs('#fmSearch'); if (si) si.value = ''; } else { const have = new Set(d.items.map(i => i.path)); S.sel.forEach(p => { if (!have.has(p)) S.sel.delete(p); }); } if (opts.select) { S.sel = new Set(opts.select); } S.search = null; ls.set('path', S.path); if (d.truncated) toast('Pasta com muitos itens: mostrando os primeiros 10.000', 'er'); render(); if (opts.select && opts.select.length) { const el = qs('.sel', body); if (el) el.scrollIntoView({ block: 'nearest' }); } return true; } catch (e) { if (!S.loaded) { S.error = e.message; S.loaded = true; S.items = []; S.path = target; render(); } else fail(e); return false; } finally { setBusy(false); } } const go = p => load(p); function setBusy(b) { S.busy = b; const l = qs('#fmLoad'); if (!l) return; if (b) { l.style.opacity = '1'; l.classList.add('on'); } else { l.classList.remove('on'); l.style.width = '100%'; setTimeout(() => { l.style.opacity = '0'; l.style.width = ''; }, 250); } } function computeView() { let list = S.search ? S.search.items.slice() : S.items.slice(); if (!S.hidden) list = list.filter(i => i.name[0] !== '.'); if (S.filter && !S.search) { const f = S.filter.toLowerCase(); list = list.filter(i => i.name.toLowerCase().includes(f)); } const k = S.sort.k, d = S.sort.d; const col = new Intl.Collator('pt-BR', { numeric: true, sensitivity: 'base' }); list.sort((a, b) => { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; let r = 0; if (k === 'size') r = (a.size || 0) - (b.size || 0); else if (k === 'mtime') r = a.mtime - b.mtime; else if (k === 'perm') r = a.perm.localeCompare(b.perm); else if (k === 'owner') r = col.compare(a.owner + ':' + a.group, b.owner + ':' + b.group); if (!r) r = col.compare(a.name, b.name); return r * d; }); S.view = list; } /* ------------------------------------------------------------ renderização */ function render() { computeView(); renderCrumbs(); renderPlaces(); if (S.error) { body.innerHTML = '
' + E(S.error) + '
'; } else if (S.mode === 'grid') { body.innerHTML = S.view.length ? '
' + S.view.map(tileHtml).join('') + '
' : emptyHtml(); } else { const h = (k, l, r) => '' + l + (S.sort.k === k ? svg(S.sort.d > 0 ? 'sortU' : 'sortD') : '') + ''; const all = S.view.length && S.view.every(i => S.sel.has(i.path)), some = !all && S.view.some(i => S.sel.has(i.path)); body.innerHTML = '
' + h('name', 'Nome') + h('size', 'Tamanho', 1) + h('mtime', 'Modificado') + h('perm', 'Permissões') + h('owner', 'Dono') + '
' + (S.view.length ? S.view.map(rowHtml).join('') : emptyHtml()); } renderState(); } function emptyHtml() { if (S.search) return '
' + fileIcon({ type: 'file', name: 'x' }, true) + '
Nada encontradoNenhum item com "' + E(S.search.q) + '" em ' + E(S.search.root) + '
'; if (S.filter) return '
Nenhum item corresponde ao filtroPressione Enter para buscar também nas subpastas
'; return '
' + fileIcon({ type: 'dir', name: '' }) + '
Pasta vaziaArraste arquivos para cá ou use o botão Enviar
'; } function clipCls(it) { return S.clip && S.clip.mode === 'cut' && S.clip.paths.includes(it.path) ? ' cut' : ''; } function rowHtml(it, i) { const sel = S.sel.has(it.path); const own = it.owner + (it.group !== it.owner ? ':' + it.group : ''); return '
' + '' + '' + fileIcon(it) + '' + E(it.name) + '' + (it.link != null ? '→ ' + E(it.link) + '' : '') + (S.search ? '' + E(dirname(it.path)) + '' : '') + '' + '' + (it.type === 'dir' ? '—' : fsize(it.size)) + '' + '' + fdate(it.mtime) + '' + '' + it.perm.slice(-3) + '' + rwx(it.perm) + '' + '' + E(own) + '
'; } function tileHtml(it, i) { const sel = S.sel.has(it.path); const thumb = isImg(it) && (it.size || 0) < 6 * 1024 * 1024; return '
' + (thumb ? '' : '' + fileIcon(it, true) + '') + '' + E(it.name) + '' + (it.type === 'dir' ? 'pasta' : fsize(it.size)) + '
'; } function renderCrumbs() { const c = qs('#fmCrumbs'); if (!c || c.querySelector('input')) return; const parts = S.path.split('/').filter(Boolean); let acc = '', h = '
/'; parts.forEach((p, i) => { acc += '/' + p; h += (i ? '/' : '') + '' + E(p) + ''; }); c.innerHTML = h + '
'; const cr = c.firstChild; cr.scrollLeft = cr.scrollWidth; } function renderPlaces() { qsa('.fm-place').forEach(el => el.classList.toggle('on', el.dataset.go === S.path)); const used = S.total ? (S.total - S.free) / S.total * 100 : 0; const f = qs('#fmFree'); if (f) f.textContent = S.total ? fsize(S.free) + ' de ' + fsize(S.total) : '—'; const b = qs('#fmDiskBar'); if (b) { b.style.width = used + '%'; b.style.background = used > 92 ? 'var(--bad)' : used > 80 ? 'var(--warn)' : 'var(--disk)'; } } function selItems() { return S.view.filter(i => S.sel.has(i.path)).concat(S.search ? [] : S.items.filter(i => S.sel.has(i.path) && !S.view.includes(i))); } function renderState() { const sel = selItems(), n = sel.length; qsa('.fm-tools [data-need]').forEach(b => { const need = b.dataset.need; b.disabled = need === 'one' ? n !== 1 : need === 'archive' ? !(n === 1 && isArchive(sel[0])) : n < 1; }); qs('[data-a=back]', root).disabled = !S.back.length; qs('[data-a=fwd]', root).disabled = !S.fwd.length; qs('[data-a=up]', root).disabled = S.path === '/'; const pb = qs('[data-a=paste]', root); pb.disabled = !S.clip || !!S.search; const cc = qs('#fmClipCnt'); cc.hidden = !S.clip; if (S.clip) cc.textContent = S.clip.paths.length; qs('#fmHidden').innerHTML = svg(S.hidden ? 'eye' : 'eyeOff'); qs('#fmHidden').classList.toggle('on', !S.hidden); qs('#fmMode').innerHTML = svg(S.mode === 'grid' ? 'list' : 'grid'); ['mkdir', 'newfile', 'upload', 'uploadDir'].forEach(a => { qs('[data-a=' + a + ']', root).disabled = !!S.search || !!S.error; }); // linhas qsa('[data-i]', body).forEach(el => { const it = S.view[+el.dataset.i]; if (it) el.classList.toggle('sel', S.sel.has(it.path)); }); const hc = qs('.fm-head .chk', body); if (hc) { const all = S.view.length && S.view.every(i => S.sel.has(i.path)); const some = !all && S.view.some(i => S.sel.has(i.path)); hc.className = 'chk ' + (all ? 'on' : some ? 'part' : ''); } // status const dirs = S.view.filter(i => i.type === 'dir').length, files = S.view.length - dirs; const sz = sel.reduce((a, i) => a + (i.size || 0), 0); let h = S.search ? '' + S.search.items.length + (S.search.limited ? '+' : '') + ' resultado(s) para "' + E(S.search.q) + '"' : '' + S.view.length + ' itens · ' + dirs + ' pastas, ' + files + ' arquivos'; if (n) h += '' + n + ' selecionado(s)' + (sz ? ' · ' + fsize(sz) : '') + ''; if (!S.writable && !S.search) h += 'somente leitura'; h += ''; if (S.clip) h += '' + S.clip.paths.length + ' item(s) ' + (S.clip.mode === 'cut' ? 'recortado(s)' : 'copiado(s)') + ''; qs('#fmStatus').innerHTML = h; } /* ------------------------------------------------------------ seleção */ function itemFromEl(el) { const r = el && el.closest('[data-i]'); return r ? S.view[+r.dataset.i] : null; } function selectClick(it, e) { const idx = S.view.indexOf(it); if (e.shiftKey && S.anchor != null) { const a = S.view.findIndex(i => i.path === S.anchor); const [x, y] = a < idx ? [a, idx] : [idx, a]; if (!(e.ctrlKey || e.metaKey)) S.sel.clear(); for (let i = x; i <= y; i++) if (S.view[i]) S.sel.add(S.view[i].path); } else if (e.ctrlKey || e.metaKey || e.toggle) { S.sel.has(it.path) ? S.sel.delete(it.path) : S.sel.add(it.path); S.anchor = it.path; } else { S.sel = new Set([it.path]); S.anchor = it.path; } renderState(); } /* ------------------------------------------------------------ abrir */ function open(it) { if (!it) return; if (it.type === 'dir') { if (S.search) return load(it.path); return go(it.path); } if (it.broken) return toast('Link quebrado: ' + it.link, 'er'); if (isImg(it)) return viewImage(it); if (isArchive(it)) return doExtract(it); if (isMedia(it)) return window.open(dlUrl(it.path, true), '_blank', 'noopener'); if (isText(it)) return editFile(it.path); return download([it]); } /* ------------------------------------------------------------ ações */ async function doMkdir() { const n = await prompt('Nova pasta', 'Nome da pasta em ' + S.path, 'nova-pasta', 'folderPlus'); if (!n) return; try { const d = await api('mkdir', { path: S.path, name: n }); await load(null, { select: [d.path] }); toast('Pasta criada'); } catch (e) { fail(e); } } async function doNewFile() { const n = await prompt('Novo arquivo', 'Nome do arquivo em ' + S.path, 'novo-arquivo.txt', 'filePlus'); if (!n) return; try { const d = await api('newfile', { path: S.path, name: n }); await load(null, { select: [d.path] }); editFile(d.path); } catch (e) { fail(e); } } async function doRename(it) { it = it || selItems()[0]; if (!it) return; const n = await prompt('Renomear', 'Novo nome para "' + it.name + '"', it.name, 'rename'); if (!n || n === it.name) return; try { const d = await api('rename', { path: it.path, name: n }); await (S.search ? runSearch(S.search.q) : load(null, { select: [d.path] })); toast('Renomeado'); } catch (e) { fail(e); } } async function doDelete(list) { list = list || selItems(); if (!list.length) return; const paths = list.map(i => i.path); const ok = await confirmDlg('Excluir ' + (list.length === 1 ? '"' + list[0].name + '"' : list.length + ' itens') + '?', 'Os itens serão apagados permanentemente, incluindo todo o conteúdo das pastas. Isso não pode ser desfeito.' + namesHtml(paths), 'Excluir', true); if (!ok) return; setBusy(true); try { const d = await api('delete', { paths }); S.sel.clear(); if (S.clip) { S.clip.paths = S.clip.paths.filter(p => !paths.includes(p)); if (!S.clip.paths.length) S.clip = null; } await (S.search ? runSearch(S.search.q) : load()); toast(d.count + ' item(s) excluído(s)'); } catch (e) { fail(e); await load(); } finally { setBusy(false); } } function doClip(mode, list) { list = list || selItems(); if (!list.length) return; S.clip = { mode, paths: list.map(i => i.path) }; toast(list.length + ' item(s) ' + (mode === 'cut' ? 'recortado(s)' : 'copiado(s)') + '. Abra a pasta de destino e clique em Colar.'); render(); } async function doPaste(dest) { if (!S.clip) return; dest = dest || S.path; const { mode, paths } = S.clip; if (mode === 'cut' && paths.every(p => dirname(p) === dest)) { S.clip = null; render(); return; } setBusy(true); try { const d = await api(mode === 'cut' ? 'move' : 'copy', { paths, dest }); if (mode === 'cut') S.clip = null; await load(null, { select: dest === S.path ? d.paths : [] }); toast(d.paths.length + ' item(s) ' + (mode === 'cut' ? 'movido(s)' : 'colado(s)') + (dest !== S.path ? ' em ' + dest : '')); } catch (e) { fail(e); await load(); } finally { setBusy(false); } } async function moveTo(paths, dest, copy) { paths = paths.filter(p => p !== dest && dirname(p) !== dest || copy); if (!paths.length) return; if (!copy && paths.some(p => dest === p || dest.startsWith(p + '/'))) return toast('Não é possível mover uma pasta para dentro dela mesma', 'er'); setBusy(true); try { const d = await api(copy ? 'copy' : 'move', { paths, dest }); await load(); toast(d.paths.length + ' item(s) ' + (copy ? 'copiado(s)' : 'movido(s)') + ' para ' + dest); } catch (e) { fail(e); } finally { setBusy(false); } } async function doZip(list) { list = list || selItems(); if (!list.length) return; const def = (list.length === 1 ? list[0].name.replace(/\.[^.]+$/, '') || list[0].name : basename(S.path) || 'arquivos') + '.zip'; const n = await prompt('Compactar', 'Nome do arquivo .zip (' + list.length + ' item(s))', def, 'zip'); if (!n) return; setBusy(true); try { const d = await api('zip', { paths: list.map(i => i.path), dest: S.search ? dirname(list[0].path) : S.path, name: n }); await load(null, { select: [d.path] }); toast('Compactado: ' + basename(d.path)); } catch (e) { fail(e); } finally { setBusy(false); } } async function doExtract(it) { it = it || selItems()[0]; if (!it || !isArchive(it)) return; const stem = it.name.replace(/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i, ''); const r = await dialog({ title: 'Extrair "' + it.name + '"', icon: 'unzip', html: 'Onde extrair o conteúdo?', buttons: [{ label: 'Cancelar', value: null }, { label: 'Extrair aqui', value: 'here' }, { label: 'Extrair para "' + stem + '/"', value: 'folder', kind: 'pri' }] }); if (!r) return; setBusy(true); try { const d = await api('extract', { path: it.path, here: r === 'here' }); await load(null, { select: r === 'here' ? [] : [d.path] }); toast('Extraído em ' + d.path); } catch (e) { fail(e); } finally { setBusy(false); } } function download(list) { list = list || selItems(); if (!list.length) return; const a = document.createElement('a'); a.href = list.length === 1 ? dlUrl(list[0].path) : '?fm=download&t=' + encodeURIComponent(TOKEN) + '&paths=' + encodeURIComponent(JSON.stringify(list.map(i => i.path))); a.download = ''; document.body.appendChild(a); a.click(); a.remove(); if (list.length > 1 || list[0].type === 'dir') toast('Compactando para download…'); } function goTerminal(dir) { location.hash = '#terminal'; setTimeout(() => { if (typeof window.tRun === 'function') window.tRun('cd ' + shq(dir)); }, 120); } /* ------------------------------------------------------------ permissões */ async function doPerm(list) { list = list || selItems(); if (!list.length) return; const one = list.length === 1 ? list[0] : null; const perm = one ? one.perm.slice(-3) : '644'; const hasDir = list.some(i => i.type === 'dir'); const owner = one ? one.owner : '', group = one ? one.group : ''; const who = [['Dono', 6], ['Grupo', 3], ['Outros', 0]], bits = [['Ler', 4], ['Escrever', 2], ['Executar', 1]]; const html = (one ? '' + E(one.name) + '' : '' + list.length + ' itens') + '
' + bits.map(b => '' + b[0] + '').join('') + who.map(w => '' + w[0] + '' + bits.map(b => '').join('')).join('') + '
' + '
Permissão (octal)
Equivale a
' + '
Dono
Grupo
' + (USER !== 'root' ? '
Mudar o dono só funciona quando o painel roda como root.
' : '') + (hasDir ? '' + '' : '') + '
Comuns: 644 arquivos · 755 pastas e scripts · 600 privado · 775 grupo pode editar
'; const r = await dialog({ title: 'Permissões', icon: 'lock', wide: true, html, buttons: [{ label: 'Cancelar', value: null }, { label: 'Aplicar', value: 'ok', kind: 'pri' }], onOpen(ov) { const oct = qs('#pmOct', ov), boxes = qsa('[data-bit]', ov); const fromOct = () => { const v = parseInt(oct.value.slice(-3), 8); if (isNaN(v)) return; boxes.forEach(b => b.checked = !!(v & +b.dataset.bit)); qs('#pmRwx', ov).textContent = rwx(oct.value.slice(-3)); }; const toOct = () => { const v = boxes.reduce((a, b) => a + (b.checked ? +b.dataset.bit : 0), 0); const s = oct.value.length === 4 ? oct.value[0] : ''; oct.value = s + v.toString(8).padStart(3, '0'); qs('#pmRwx', ov).textContent = rwx(v.toString(8)); }; oct.addEventListener('input', fromOct); boxes.forEach(b => b.addEventListener('change', toOct)); fromOct(); const rec = qs('#pmRec', ov); if (rec) rec.addEventListener('change', () => { qs('#pmRecBox', ov).style.display = rec.checked ? 'block' : 'none'; qs('.perm-grid', ov).style.opacity = rec.checked ? '.4' : ''; oct.disabled = rec.checked; }); }, validate(ov) { const rec = qs('#pmRec', ov) && qs('#pmRec', ov).checked; const o = { mode: qs('#pmOct', ov).value.trim(), rec, f: rec ? qs('#pmF', ov).value.trim() : '', d: rec ? qs('#pmD', ov).value.trim() : '', own: qs('#pmOwn', ov).value.trim(), grp: qs('#pmGrp', ov).value.trim() }; const re = /^[0-7]{3,4}$/; if (!re.test(rec ? o.f : o.mode) || (rec && !re.test(o.d))) { toast('Permissão inválida: use números de 0 a 7 (ex: 755)', 'er'); return false; } return o; } }); if (!r) return; const paths = list.map(i => i.path); setBusy(true); try { const modeChanged = r.rec || !one || r.mode.slice(-3) !== one.perm.slice(-3) || r.mode.length === 4; if (modeChanged) await api('chmod', r.rec ? { paths, mode: r.f, dmode: r.d, recursive: true } : { paths, mode: r.mode }); const ownChanged = (r.own && r.own !== owner) || (r.grp && r.grp !== group) || (r.rec && (r.own || r.grp)); if (ownChanged) await api('chown', { paths, owner: r.own, group: r.grp, recursive: r.rec }); await load(); toast('Permissões atualizadas'); } catch (e) { fail(e); await load(); } finally { setBusy(false); } } /* ------------------------------------------------------------ propriedades */ async function doProps(it) { it = it || selItems()[0] || { path: S.path, name: basename(S.path), type: 'dir' }; let st; try { st = await api('stat', { path: it.path }); } catch (e) { return fail(e); } const row = (k, v) => '
' + k + '
' + v + '
'; const html = '
' + row('Nome', E(st.name)) + row('Local', '' + E(dirname(st.path)) + '') + row('Tipo', st.is_dir ? 'Pasta' : E(st.mime)) + (st.link != null ? row('Link para', '' + E(st.link) + '' + (st.broken ? ' (quebrado)' : '')) : '') + row('Tamanho', st.is_dir ? '—' : fsize(st.size) + ' (' + (st.size || 0).toLocaleString('pt-BR') + ' bytes)') + row('Modificado', fdate(st.mtime)) + row('Acessado', fdate(st.atime)) + row('Permissões', '' + st.perm.slice(-3) + ' ' + rwx(st.perm) + '') + row('Dono', E(st.owner + ' : ' + st.group)) + '
'; dialog({ title: 'Propriedades', icon: 'info', wide: true, html, buttons: [{ label: 'Fechar', value: true, kind: 'pri' }], onOpen(ov) { qsa('[data-copy]', ov).forEach(b => b.onclick = () => copyText(b.dataset.copy).then(() => toast('Caminho copiado'))); const c = qs('#pCalc', ov); if (c) c.onclick = async () => { c.textContent = 'calculando…'; c.disabled = true; try { const d = await api('stat', { path: st.path, du: true }); qs('#pDu', ov).textContent = fsize(d.size) + ' · ' + (d.count || 0).toLocaleString('pt-BR') + ' itens'; c.remove(); } catch (e) { fail(e); c.textContent = 'calcular'; c.disabled = false; } }; } }); } /* ------------------------------------------------------------ busca */ async function runSearch(q) { q = (q || '').trim(); if (!q) return; setBusy(true); try { const d = await api('search', { path: S.path, q }); S.search = { q, root: S.path, items: d.items, limited: d.limited }; S.sel.clear(); render(); } catch (e) { fail(e); } finally { setBusy(false); } } function endSearch() { S.search = null; S.filter = ''; qs('#fmSearch').value = ''; S.sel.clear(); render(); } /* ------------------------------------------------------------ upload */ let upPanel = null, upQueue = [], upRunning = false, upTouched = new Set(); function upUI() { if (!upPanel) { upPanel = document.createElement('div'); upPanel.className = 'fm-up'; upPanel.innerHTML = '
' + svg('upload') + 'Enviando
'; document.body.appendChild(upPanel); qs('#upClose', upPanel).onclick = () => { if (upRunning && !window.confirm('Cancelar os envios em andamento?')) return; upQueue.forEach(u => { if (u.xhr) u.xhr.abort(); u.cancel = true; }); upPanel.remove(); upPanel = null; upQueue = []; }; } const done = upQueue.filter(u => u.st === 'ok').length, err = upQueue.filter(u => u.st === 'er').length; qs('#upTitle', upPanel).textContent = upRunning ? 'Enviando ' + (done + err + 1 > upQueue.length ? upQueue.length : done + err + 1) + ' de ' + upQueue.length : 'Concluído · ' + done + ' enviado(s)' + (err ? ', ' + err + ' com erro' : ''); qs('#upList', upPanel).innerHTML = upQueue.map(u => '
' + E(u.rel + u.name) + '' + (u.st === 'ok' ? 'enviado' : u.st === 'er' ? E(u.err) : u.st === 'up' ? Math.round(u.pct) + '% de ' + fsize(u.file.size) : 'na fila · ' + fsize(u.file.size)) + '
').join(''); } async function startUpload(entries) { // entries: [{file, rel}] rel = subpasta relativa ("" ou "pasta/sub/") if (!entries.length) return; const dest = S.path; const top = new Set(entries.map(e => (e.rel ? e.rel.split('/')[0] : e.file.name))); const existing = new Set(S.items.map(i => i.name)); const conflicts = [...top].filter(n => existing.has(n)); let overwrite = false; if (conflicts.length) { const r = await dialog({ title: conflicts.length === 1 ? '"' + conflicts[0] + '" já existe' : conflicts.length + ' itens já existem', icon: 'info', html: 'O que fazer com os itens que já existem em ' + E(dest) + '?' + namesHtml(conflicts), buttons: [{ label: 'Cancelar', value: null }, { label: 'Pular', value: 'skip' }, { label: 'Manter os dois', value: 'keep' }, { label: 'Substituir', value: 'over', kind: 'pri' }] }); if (!r) return; if (r === 'skip') entries = entries.filter(e => !conflicts.includes(e.rel ? e.rel.split('/')[0] : e.file.name)); overwrite = r === 'over'; if (!entries.length) return; } // cria as subpastas primeiro const dirs = [...new Set(entries.filter(e => e.rel).flatMap(e => { const p = e.rel.replace(/\/$/, '').split('/'); return p.map((_, i) => p.slice(0, i + 1).join('/')); }))].sort((a, b) => a.split('/').length - b.split('/').length); for (const d of dirs) { try { await api('mkdir', { path: pjoin(dest, dirname('/' + d) === '/' ? '' : dirname('/' + d).slice(1)).replace(/\/$/, '') || '/', name: basename(d) }); } catch (e) { if (!/já existe/i.test(e.message)) { fail(e); return; } } } entries.forEach(e => upQueue.push({ file: e.file, name: e.file.name, rel: e.rel || '', dest: e.rel ? pjoin(dest, e.rel.replace(/\/$/, '')) : dest, st: 'q', pct: 0, overwrite })); upTouched.add(dest); upUI(); if (!upRunning) runQueue(); } async function runQueue() { upRunning = true; let u; while ((u = upQueue.find(x => x.st === 'q' && !x.cancel))) { u.st = 'up'; upUI(); await new Promise(res => { const fd = new FormData(); fd.append('dest', u.dest); fd.append('name', u.name); fd.append('overwrite', u.overwrite ? '1' : '0'); fd.append('file', u.file, u.name); const x = new XMLHttpRequest(); u.xhr = x; x.open('POST', '?fm=upload'); x.setRequestHeader('X-LKM-Token', TOKEN); let last = 0; x.upload.onprogress = ev => { if (ev.lengthComputable) { u.pct = ev.loaded / ev.total * 100; if (Date.now() - last > 150) { last = Date.now(); upUI(); } } }; x.onload = () => { let d = null; try { d = JSON.parse(x.responseText); } catch (e) { } if (d && d.ok) u.st = 'ok'; else { u.st = 'er'; u.err = (d && d.error) || (x.status === 413 ? 'Arquivo grande demais para o servidor' : 'Erro ' + x.status); } res(); }; x.onerror = () => { u.st = 'er'; u.err = 'Falha de conexão'; res(); }; x.onabort = () => { u.st = 'er'; u.err = 'Cancelado'; res(); }; x.send(fd); }); u.xhr = null; upUI(); } upRunning = false; upUI(); if (upTouched.has(S.path) || [...upTouched].some(d => d.startsWith(S.path))) load(); upTouched.clear(); const err = upQueue.filter(x => x.st === 'er').length; if (!err) setTimeout(() => { if (upPanel && !upRunning) { upPanel.remove(); upPanel = null; upQueue = []; } }, 4000); } async function entriesFromDrop(dt) { const items = dt.items ? [...dt.items].filter(i => i.kind === 'file') : []; const roots = items.map(i => i.webkitGetAsEntry && i.webkitGetAsEntry()).filter(Boolean); if (!roots.length) return [...(dt.files || [])].map(f => ({ file: f, rel: '' })); const out = []; const readAll = r => new Promise(res => { const all = []; const next = () => r.readEntries(b => { if (!b.length) return res(all); all.push(...b); next(); }, () => res(all)); next(); }); async function walk(en, rel) { if (en.isFile) await new Promise(res => en.file(f => { out.push({ file: f, rel }); res(); }, () => res())); else if (en.isDirectory) { const kids = await readAll(en.createReader()); if (!kids.length) out.push({ dir: rel + en.name + '/' }); for (const k of kids) await walk(k, rel + en.name + '/'); } } for (const r of roots) await walk(r, ''); return out.filter(e => e.file); } /* ------------------------------------------------------------ editor */ const CMV = '5.65.16', CMB = window.LKM_CM_BASE || 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/' + CMV + '/'; const loaded = {}; function loadRes(url) { if (loaded[url]) return loaded[url]; loaded[url] = new Promise((res, rej) => { let el; if (url.endsWith('.css')) { el = document.createElement('link'); el.rel = 'stylesheet'; el.href = url; } else { el = document.createElement('script'); el.src = url; el.async = false; } el.onload = res; el.onerror = () => { delete loaded[url]; rej(new Error('falha ao carregar ' + url)); }; document.head.appendChild(el); }); return loaded[url]; } const MODES = { php: ['PHP', 'application/x-httpd-php', ['xml', 'javascript', 'css', 'htmlmixed', 'clike', 'php']], phtml: ['PHP', 'application/x-httpd-php', ['xml', 'javascript', 'css', 'htmlmixed', 'clike', 'php']], js: ['JavaScript', 'javascript', ['javascript']], mjs: ['JavaScript', 'javascript', ['javascript']], cjs: ['JavaScript', 'javascript', ['javascript']], jsx: ['JSX', 'jsx', ['xml', 'javascript', 'jsx']], ts: ['TypeScript', 'text/typescript', ['javascript']], tsx: ['TSX', 'text/typescript-jsx', ['xml', 'javascript', 'jsx']], json: ['JSON', 'application/json', ['javascript']], css: ['CSS', 'css', ['css']], scss: ['SCSS', 'text/x-scss', ['css']], less: ['LESS', 'text/x-less', ['css']], html: ['HTML', 'htmlmixed', ['xml', 'javascript', 'css', 'htmlmixed']], htm: ['HTML', 'htmlmixed', ['xml', 'javascript', 'css', 'htmlmixed']], vue: ['Vue', 'htmlmixed', ['xml', 'javascript', 'css', 'htmlmixed']], xml: ['XML', 'xml', ['xml']], svg: ['SVG', 'xml', ['xml']], sh: ['Shell', 'shell', ['shell']], bash: ['Shell', 'shell', ['shell']], zsh: ['Shell', 'shell', ['shell']], py: ['Python', 'python', ['python']], sql: ['SQL', 'text/x-sql', ['sql']], yml: ['YAML', 'yaml', ['yaml']], yaml: ['YAML', 'yaml', ['yaml']], md: ['Markdown', 'markdown', ['xml', 'markdown']], ini: ['INI', 'properties', ['properties']], conf: ['Config', 'properties', ['properties']], cnf: ['Config', 'properties', ['properties']], env: ['ENV', 'properties', ['properties']], properties: ['INI', 'properties', ['properties']], c: ['C', 'text/x-csrc', ['clike']], h: ['C', 'text/x-csrc', ['clike']], cpp: ['C++', 'text/x-c++src', ['clike']], java: ['Java', 'text/x-java', ['clike']], cs: ['C#', 'text/x-csharp', ['clike']], kt: ['Kotlin', 'text/x-kotlin', ['clike']], go: ['Go', 'go', ['go']], lua: ['Lua', 'lua', ['lua']], rb: ['Ruby', 'ruby', ['ruby']], rs: ['Rust', 'rust', ['rust']], toml: ['TOML', 'toml', ['toml']], nginx: ['Nginx', 'nginx', ['nginx']], dockerfile: ['Dockerfile', 'text/x-sh', ['shell']] }; function modeFor(name) { const e = extOf(name) || name.toLowerCase().replace(/^\./, ''); if (/^(htaccess|apache2\.conf)$/.test(e) || /\.conf$/.test(name) && /apache|sites-/.test(name)) return MODES.conf; if (/^(bashrc|profile|bash_profile|zshrc|bash_aliases)$/.test(e)) return MODES.sh; if (/^dockerfile$/i.test(name)) return MODES.dockerfile; return MODES[e] || ['Texto', null, []]; } async function loadCM(deps) { await loadRes(CMB + 'codemirror.min.css'); await loadRes(CMB + 'codemirror.min.js'); // os extras são opcionais: se algum falhar o editor abre mesmo assim await Promise.allSettled([ loadRes(CMB + 'addon/dialog/dialog.min.css'), loadRes(CMB + 'addon/dialog/dialog.min.js'), loadRes(CMB + 'addon/search/searchcursor.min.js'), loadRes(CMB + 'addon/search/search.min.js'), loadRes(CMB + 'addon/search/jump-to-line.min.js'), loadRes(CMB + 'addon/edit/matchbrackets.min.js'), loadRes(CMB + 'addon/edit/closebrackets.min.js'), loadRes(CMB + 'addon/selection/active-line.min.js'), loadRes(CMB + 'addon/mode/simple.min.js') ]); for (const m of deps) { try { await loadRes(CMB + 'mode/' + m + '/' + m + '.min.js'); } catch (e) { } } } let ED = null; window.addEventListener('beforeunload', e => { if (ED && ED.dirty()) { e.preventDefault(); e.returnValue = ''; } }); async function editFile(path) { if (ED) return; const name = basename(path); const [label, mode, deps] = modeFor(name); const box = document.createElement('div'); box.className = 'fm-ed'; box.innerHTML = '
' + fileIcon({ type: 'file', name }) + '
' + E(dirname(path) === '/' ? '' : dirname(path)) + '/' + E(name) + '
' + E(label) + '' + '' + '
' + '
Abrindo ' + E(name) + '…
Ln 1, Col 1Ctrl+S salvar · Ctrl+F buscar · Esc fechar
'; document.body.appendChild(box); let doc; try { doc = await api('read', { path }); } catch (e) { box.remove(); fail(e); if (/binário/.test(e.message)) { const it = S.items.find(i => i.path === path); if (it && await confirmDlg('Arquivo binário', 'Este arquivo não pode ser editado como texto. Deseja baixá-lo?', 'Baixar')) download([it]); } return; } let wrap = ls.get('wrap', false), cm = null, ta = null, clean = doc.content, mtime = doc.mtime, saving = false; const enc = doc.enc; if (enc === 'latin1') { const b = qs('#edEnc', box); b.hidden = false; b.textContent = 'ISO-8859-1'; } const bodyEl = qs('#edBody', box); const setDirty = () => box.classList.toggle('is-dirty', ED.dirty()); const getVal = () => cm ? cm.getValue() : ta.value; const usesTabs = (doc.content.match(/^\t/mg) || []).length > (doc.content.match(/^ {2,}/mg) || []).length; try { await Promise.race([loadCM(deps || []), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 8000))]); bodyEl.innerHTML = ''; cm = window.CodeMirror(bodyEl, { value: doc.content, mode: mode || 'text/plain', theme: 'lkm', lineNumbers: true, indentUnit: 4, tabSize: 4, indentWithTabs: usesTabs, lineWrapping: wrap, matchBrackets: true, autoCloseBrackets: true, styleActiveLine: true, readOnly: !doc.writable, autofocus: true, viewportMargin: 20, extraKeys: { 'Ctrl-S': () => save(), 'Cmd-S': () => save(), 'Ctrl-F': 'findPersistent', 'Cmd-F': 'findPersistent', 'Ctrl-G': 'jumpToLine', Tab: c => { if (c.somethingSelected()) c.indentSelection('add'); else c.replaceSelection(c.getOption('indentWithTabs') ? '\t' : ' '.repeat(c.getOption('indentUnit')), 'end'); }, 'Shift-Tab': c => c.indentSelection('subtract') } }); cm.on('change', setDirty); cm.on('cursorActivity', () => { const p = cm.getCursor(); qs('#edPos', box).textContent = 'Ln ' + (p.line + 1) + ', Col ' + (p.ch + 1); }); } catch (e) { bodyEl.innerHTML = ''; ta = qs('textarea', bodyEl); ta.value = doc.content; ta.readOnly = !doc.writable; ta.focus(); ta.addEventListener('input', setDirty); ta.addEventListener('keydown', ev => { if (ev.key === 'Tab') { ev.preventDefault(); const s = ta.selectionStart, en = ta.selectionEnd; ta.setRangeText(usesTabs ? '\t' : ' ', s, en, 'end'); setDirty(); } }); const pos = () => { const t = ta.value.slice(0, ta.selectionStart).split('\n'); qs('#edPos', box).textContent = 'Ln ' + t.length + ', Col ' + (t[t.length - 1].length + 1); }; ta.addEventListener('keyup', pos); ta.addEventListener('click', pos); qs('#edFind', box).disabled = true; qs('#edInfo', box).textContent = 'editor simples (sem internet para carregar o editor completo)'; } const info = () => { const v = getVal(); qs('#edInfo', box).textContent = (ta ? qs('#edInfo', box).textContent.split(' · ').pop() + ' · ' : '') + v.split('\n').length + ' linhas · ' + fsize(new Blob([v]).size) + (doc.writable ? '' : ' · somente leitura'); }; if (cm) info(); ED = { dirty: () => getVal() !== clean, path }; async function save(force) { if (saving || !doc.writable) return; saving = true; const b = qs('#edSave', box); b.disabled = true; const val = getVal(); try { const d = await api('save', { path, content: val, enc, mtime, force: !!force }); clean = val; mtime = d.mtime; setDirty(); if (cm) info(); toast('Salvo: ' + name); const it = S.items.find(i => i.path === path); if (it) { it.size = d.size; it.mtime = d.mtime; } } catch (e) { if (e.message === 'CONFLICT') { saving = false; b.disabled = false; if (await confirmDlg('Arquivo alterado por fora', 'O arquivo ' + E(name) + ' foi modificado no servidor depois que você abriu. Salvar mesmo assim e sobrescrever?', 'Sobrescrever', true)) return save(true); return; } fail(e); } finally { saving = false; b.disabled = false; (cm || ta).focus(); } } async function close() { if (ED && ED.dirty() && !(await confirmDlg('Descartar alterações?', 'Você tem alterações não salvas em ' + E(name) + '.', 'Descartar', true))) return; document.removeEventListener('keydown', keys, true); box.remove(); ED = null; load(); body.focus(); } const keys = ev => { if (!document.body.contains(box) || qs('.fm-ov')) return; if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 's') { ev.preventDefault(); save(); } else if (ev.key === 'Escape' && !qs('.CodeMirror-dialog', box)) { ev.preventDefault(); close(); } }; document.addEventListener('keydown', keys, true); qs('#edSave', box).onclick = () => save(); qs('#edClose', box).onclick = close; qs('#edDl', box).onclick = () => download([{ path, type: 'file', name }]); qs('#edFind', box).onclick = () => cm && cm.execCommand('findPersistent'); qs('#edWrap', box).classList.toggle('on', wrap); qs('#edWrap', box).onclick = () => { wrap = !wrap; ls.set('wrap', wrap); qs('#edWrap', box).classList.toggle('on', wrap); if (cm) cm.setOption('lineWrapping', wrap); else ta.classList.toggle('wrap', wrap); }; if (!doc.writable) toast('Arquivo somente leitura', 'er'); } /* ------------------------------------------------------------ visualizador */ function viewImage(it) { const imgs = S.view.filter(isImg); let idx = Math.max(0, imgs.findIndex(i => i.path === it.path)); const v = document.createElement('div'); v.className = 'fm-view'; v.innerHTML = '
' + '
' + (imgs.length > 1 ? '' : '') + '
'; document.body.appendChild(v); const show = () => { const c = imgs[idx]; qs('#vImg', v).src = dlUrl(c.path, true); qs('#vName', v).textContent = c.name + ' · ' + fsize(c.size) + ' · ' + (idx + 1) + '/' + imgs.length; }; const close = () => { v.remove(); document.removeEventListener('keydown', key, true); }; const step = d => { idx = (idx + d + imgs.length) % imgs.length; show(); }; const key = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'ArrowLeft') step(-1); else if (e.key === 'ArrowRight') step(1); }; document.addEventListener('keydown', key, true); qs('#vX', v).onclick = close; qs('#vDl', v).onclick = () => download([imgs[idx]]); if (qs('#vP', v)) { qs('#vP', v).onclick = () => step(-1); qs('#vN', v).onclick = () => step(1); } qs('.vb', v).addEventListener('click', e => { if (e.target.classList.contains('vb')) close(); }); show(); } /* ------------------------------------------------------------ menu de contexto */ function menu(x, y, entries) { closeMenu(); const m = document.createElement('div'); m.className = 'fm-menu'; m.id = 'fmMenu'; m.innerHTML = entries.map((e, i) => e === '-' ? '
' : '').join(''); document.body.appendChild(m); const r = m.getBoundingClientRect(); m.style.left = Math.max(6, Math.min(x, innerWidth - r.width - 6)) + 'px'; m.style.top = Math.max(6, Math.min(y, innerHeight - r.height - 6)) + 'px'; m.addEventListener('click', e => { const b = e.target.closest('button[data-k]'); if (!b || b.disabled) return; closeMenu(); entries[+b.dataset.k].fn(); }); } function closeMenu() { const m = qs('#fmMenu'); if (m) m.remove(); } function itemMenu(e, it) { if (!S.sel.has(it.path)) { S.sel = new Set([it.path]); S.anchor = it.path; renderState(); } const sel = selItems(), one = sel.length === 1 ? sel[0] : null; const ent = []; if (one) { ent.push({ l: one.type === 'dir' ? 'Abrir' : isText(one) ? 'Editar' : isImg(one) ? 'Visualizar' : isArchive(one) ? 'Extrair…' : 'Abrir', i: one.type === 'dir' ? 'open' : isText(one) ? 'edit' : isArchive(one) ? 'unzip' : 'eye', k: 'Enter', fn: () => open(one) }); if (one.type === 'file' && !isText(one) && !isImg(one)) ent.push({ l: 'Editar como texto', i: 'edit', fn: () => editFile(one.path) }); if (S.search) ent.push({ l: 'Abrir local do arquivo', i: 'folderUp', fn: () => load(dirname(one.path), { select: [one.path] }) }); } ent.push({ l: 'Baixar' + (sel.length > 1 || (one && one.type === 'dir') ? ' (.zip)' : ''), i: 'download', fn: () => download(sel) }, '-'); ent.push({ l: 'Copiar', i: 'copy', k: 'Ctrl+C', fn: () => doClip('copy', sel) }, { l: 'Recortar', i: 'cut', k: 'Ctrl+X', fn: () => doClip('cut', sel) }); if (one && one.type === 'dir' && S.clip) ent.push({ l: 'Colar dentro desta pasta', i: 'paste', fn: () => doPaste(one.path) }); ent.push('-'); ent.push({ l: 'Renomear', i: 'rename', k: 'F2', dis: !one, fn: () => doRename(one) }, { l: 'Permissões e dono', i: 'lock', fn: () => doPerm(sel) }, { l: 'Compactar (.zip)', i: 'zip', fn: () => doZip(sel) }); if (one && isArchive(one)) ent.push({ l: 'Extrair', i: 'unzip', fn: () => doExtract(one) }); ent.push('-'); if (one) ent.push({ l: 'Copiar caminho', i: 'link', fn: () => copyText(one.path).then(() => toast('Caminho copiado')) }); if (one && USER) ent.push({ l: 'Abrir no terminal', i: 'term', fn: () => goTerminal(one.type === 'dir' ? one.path : dirname(one.path)) }); if (one) ent.push({ l: 'Propriedades', i: 'info', fn: () => doProps(one) }); ent.push('-', { l: 'Excluir', i: 'trash', k: 'Del', danger: true, fn: () => doDelete(sel) }); menu(e.clientX, e.clientY, ent); } function bgMenu(e) { if (S.sel.size) { S.sel.clear(); renderState(); } const ro = !!S.search; menu(e.clientX, e.clientY, [ { l: 'Nova pasta', i: 'folderPlus', dis: ro, fn: doMkdir }, { l: 'Novo arquivo', i: 'filePlus', dis: ro, fn: doNewFile }, { l: 'Enviar arquivos', i: 'upload', dis: ro, fn: () => qs('#fmFile').click() }, '-', { l: 'Colar', i: 'paste', k: 'Ctrl+V', dis: !S.clip || ro, fn: () => doPaste() }, { l: 'Selecionar tudo', i: 'selAll', k: 'Ctrl+A', fn: selectAll }, { l: 'Atualizar', i: 'refresh', fn: () => load() }, '-', { l: 'Abrir no terminal', i: 'term', dis: !USER, fn: () => goTerminal(S.path) }, { l: 'Copiar caminho da pasta', i: 'link', fn: () => copyText(S.path).then(() => toast('Caminho copiado')) }, { l: 'Propriedades da pasta', i: 'info', fn: () => doProps({ path: S.path, name: basename(S.path), type: 'dir' }) } ]); } function selectAll() { S.sel = new Set(S.view.map(i => i.path)); renderState(); } /* ------------------------------------------------------------ eventos */ function editPath() { const c = qs('#fmCrumbs'); if (c.querySelector('input')) return; c.innerHTML = ''; const i = qs('input', c); i.focus(); i.select(); let done = false; const end = async ok => { if (done) return; done = true; const v = i.value.trim(); c.innerHTML = ''; if (ok && v && v !== S.path) { if (!(await load(v.startsWith('/') ? v : pjoin(S.path, v)))) renderCrumbs(); } else renderCrumbs(); }; i.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); end(true); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); end(false); } }); i.addEventListener('blur', () => end(false)); } const ACT = { back() { if (!S.back.length) return; S.fwd.push(S.path); load(S.back.pop(), { keepHistory: true }); }, fwd() { if (!S.fwd.length) return; S.back.push(S.path); load(S.fwd.pop(), { keepHistory: true }); }, up() { if (S.search) return endSearch(); if (S.path !== '/') load(dirname(S.path), { select: [S.path] }); }, refresh() { S.search ? runSearch(S.search.q) : load(); }, mkdir: doMkdir, newfile: doNewFile, upload() { qs('#fmFile').click(); }, uploadDir() { qs('#fmDir').click(); }, copy() { doClip('copy'); }, cut() { doClip('cut'); }, paste() { doPaste(); }, clearClip() { S.clip = null; render(); }, rename() { doRename(); }, perm() { doPerm(); }, zip() { doZip(); }, extract() { doExtract(); }, download() { download(); }, delete() { doDelete(); }, hidden() { S.hidden = !S.hidden; ls.set('hidden', S.hidden); render(); }, mode() { S.mode = S.mode === 'grid' ? 'list' : 'grid'; ls.set('mode', S.mode); render(); }, selAll() { const all = S.view.length && S.view.every(i => S.sel.has(i.path)); if (all) S.sel.clear(); else selectAll(); renderState(); }, endSearch }; function bind() { root.addEventListener('click', e => { const a = e.target.closest('[data-a]'); if (a && !a.disabled && ACT[a.dataset.a]) { e.preventDefault(); ACT[a.dataset.a](); return; } const g = e.target.closest('[data-go]'); if (g && g.classList.contains('last')) { editPath(); return; } if (g) { load(g.dataset.go); return; } const so = e.target.closest('[data-sort]'); if (so) { const k = so.dataset.sort; S.sort = { k, d: S.sort.k === k ? -S.sort.d : 1 }; ls.set('sort', S.sort); render(); return; } if (e.target.closest('#fmCrumbs') && !e.target.closest('.seg')) { editPath(); } }); body.addEventListener('click', e => { const it = itemFromEl(e.target); if (!it) { if (!e.target.closest('.fm-head') && (S.sel.size)) { S.sel.clear(); renderState(); } return; } if (e.target.closest('.chk')) { selectClick(it, { toggle: true, shiftKey: e.shiftKey }); return; } if (coarse && !e.ctrlKey && !e.shiftKey && !S.sel.size) { open(it); return; } if (coarse && S.sel.size && !e.ctrlKey && !e.shiftKey) { selectClick(it, { toggle: true }); return; } selectClick(it, e); }); body.addEventListener('dblclick', e => { const it = itemFromEl(e.target); if (it && !e.target.closest('.chk')) open(it); }); body.addEventListener('contextmenu', e => { e.preventDefault(); const it = itemFromEl(e.target); it ? itemMenu(e, it) : bgMenu(e); }); document.addEventListener('mousedown', e => { if (!e.target.closest('#fmMenu')) closeMenu(); }, true); window.addEventListener('blur', closeMenu); window.addEventListener('resize', closeMenu); body.addEventListener('scroll', closeMenu); // busca const si = qs('#fmSearch'); si.addEventListener('input', () => { if (S.search) { S.search = null; } S.filter = si.value; render(); }); si.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); runSearch(si.value); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); si.value = ''; S.filter = ''; if (S.search) endSearch(); else render(); body.focus(); } }); // arquivos para enviar qs('#fmFile').addEventListener('change', e => { startUpload([...e.target.files].map(f => ({ file: f, rel: '' }))); e.target.value = ''; }); qs('#fmDir').addEventListener('change', e => { const list = [...e.target.files].map(f => { const p = f.webkitRelativePath || f.name; const i = p.lastIndexOf('/'); return { file: f, rel: i > 0 ? p.slice(0, i + 1) : '' }; }); startUpload(list); e.target.value = ''; }); // arrastar arquivos do computador / mover itens const main = qs('.fm-main', root), drop = qs('#fmDrop'); let depth = 0, dragPaths = null; const isFiles = e => e.dataTransfer && [...e.dataTransfer.types].includes('Files') && !dragPaths; main.addEventListener('dragenter', e => { if (!isFiles(e) || S.search || S.error) return; depth++; drop.classList.add('on'); qs('#fmDropTo').textContent = 'para ' + S.path; }); main.addEventListener('dragleave', e => { if (!isFiles(e)) return; depth = Math.max(0, depth - 1); if (!depth) drop.classList.remove('on'); }); main.addEventListener('dragover', e => { if (isFiles(e)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } }); main.addEventListener('drop', async e => { if (!isFiles(e)) return; e.preventDefault(); depth = 0; drop.classList.remove('on'); if (S.search || S.error) return; startUpload(await entriesFromDrop(e.dataTransfer)); }); // mover arrastando itens root.addEventListener('dragstart', e => { const it = itemFromEl(e.target); if (!it) return; if (!S.sel.has(it.path)) { S.sel = new Set([it.path]); S.anchor = it.path; renderState(); } dragPaths = selItems().map(i => i.path); e.dataTransfer.effectAllowed = 'copyMove'; e.dataTransfer.setData('application/x-lkm', JSON.stringify(dragPaths)); e.dataTransfer.setData('text/plain', dragPaths.join('\n')); }); root.addEventListener('dragend', () => { dragPaths = null; qsa('.drop', root).forEach(x => x.classList.remove('drop')); }); const targetOf = e => { const el = e.target.closest('[data-i], [data-go]'); if (!el) return null; if (el.dataset.go) return { el, path: el.dataset.go }; const it = S.view[+el.dataset.i]; return it && it.type === 'dir' ? { el, path: it.path } : null; }; root.addEventListener('dragover', e => { if (!dragPaths) return; const t = targetOf(e); qsa('.drop', root).forEach(x => { if (!t || x !== t.el) x.classList.remove('drop'); }); if (t && !dragPaths.includes(t.path)) { e.preventDefault(); e.dataTransfer.dropEffect = e.ctrlKey || e.altKey ? 'copy' : 'move'; t.el.classList.add('drop'); } }); root.addEventListener('drop', e => { if (!dragPaths) return; const t = targetOf(e); e.preventDefault(); const paths = dragPaths; dragPaths = null; qsa('.drop', root).forEach(x => x.classList.remove('drop')); if (t && !paths.includes(t.path)) moveTo(paths, t.path, e.ctrlKey || e.altKey); }); // teclado document.addEventListener('keydown', e => { if (!root.offsetParent || ED || qs('.fm-ov') || qs('.fm-view')) return; const tag = (e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return; const k = e.key, mod = e.ctrlKey || e.metaKey; const sel = selItems(); if (k === 'Escape') { closeMenu(); if (S.sel.size) { S.sel.clear(); renderState(); } else if (S.search) endSearch(); } else if (mod && k.toLowerCase() === 'a') { e.preventDefault(); selectAll(); } else if (mod && k.toLowerCase() === 'c') { if (sel.length) { e.preventDefault(); doClip('copy'); } } else if (mod && k.toLowerCase() === 'x') { if (sel.length) { e.preventDefault(); doClip('cut'); } } else if (mod && k.toLowerCase() === 'v') { if (S.clip && !S.search) { e.preventDefault(); doPaste(); } } else if (mod && k.toLowerCase() === 'f') { e.preventDefault(); qs('#fmSearch').focus(); } else if (k === 'Delete') { if (sel.length) { e.preventDefault(); doDelete(); } } else if (k === 'F2') { if (sel.length === 1) { e.preventDefault(); doRename(); } } else if (k === 'Enter') { if (sel.length === 1) { e.preventDefault(); open(sel[0]); } } else if (k === 'Backspace' || (e.altKey && k === 'ArrowUp')) { e.preventDefault(); ACT.up(); } else if (e.altKey && k === 'ArrowLeft') { e.preventDefault(); ACT.back(); } else if (e.altKey && k === 'ArrowRight') { e.preventDefault(); ACT.fwd(); } else if ((k === 'ArrowDown' || k === 'ArrowUp') && S.view.length) { e.preventDefault(); let i = S.view.findIndex(x => x.path === S.anchor); i = i < 0 ? 0 : Math.max(0, Math.min(S.view.length - 1, i + (k === 'ArrowDown' ? 1 : -1))); selectClick(S.view[i], { shiftKey: e.shiftKey }); if (!e.shiftKey) S.anchor = S.view[i].path; const el = qs('[data-i="' + i + '"]', body); if (el) el.scrollIntoView({ block: 'nearest' }); } }); } /* ------------------------------------------------------------ inicialização */ /* componentes compartilhados com as outras abas (sites.js) */ window.UI = { E, qs, qsa, toast, fail, dialog, prompt, confirmDlg, menu, closeMenu, svg, IC, copyText, fsize, fdate, coarse }; window.FM = { go(p) { if (!inited) { S.path = p; return; } load(p); }, show() { if (!qs('#fm')) return; if (!USER) { qs('#fm').innerHTML = '
O gerenciador de arquivos está desativado. Rode o instalador do LK Monitor de novo e escolha root ou seu usuário na pergunta do terminal.
'; return; } if (!inited) { inited = true; build(); load(S.path).then(ok => { if (!ok && S.path !== HOME) { S.loaded = false; S.error = ''; load(HOME); } }); } else if (!S.busy && !ED) load(null); } }; if (location.hash === '#arquivos') window.FM.show(); })(); LKM_EOF_FMJS cat > "$DIR/fm.css" <<'LKM_EOF_FMCSS' /* LK Monitor - gerenciador de arquivos */ #fm{height:calc(100vh - 92px);min-height:420px} .fm{display:grid;grid-template-columns:210px minmax(0,1fr);gap:14px;height:100%} #fm [hidden],.fm-ed [hidden],.fm-view [hidden]{display:none!important} svg.i{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;flex:none} /* lateral */ .fm-side{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:12px 10px;display:flex;flex-direction:column;gap:2px;overflow:auto} .fm-side h3{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--dim);padding:6px 10px 6px} .fm-place{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;color:var(--mut);font-size:13.5px;cursor:pointer;border:1px solid transparent;user-select:none;white-space:nowrap} .fm-place:hover{background:var(--bg2);color:var(--tx)} .fm-place.on{background:#5b9cff1a;color:#2f8cdc;border-color:#5b9cff33} .fm-place.drop{border-color:var(--cpu);background:#5b9cff22} .fm-place small{margin-left:auto;color:var(--dim);font-size:11px;font-family:var(--mono);overflow:hidden;text-overflow:ellipsis} .fm-disk{margin-top:auto;padding:12px 10px 4px;border-top:1px solid var(--line)} .fm-disk .rl{font-size:12px;color:var(--mut);display:flex;flex-direction:column;gap:2px;margin-bottom:8px;white-space:nowrap} .fm-disk .rl span:last-child{color:var(--tx);font-family:var(--mono);font-size:11px} .fm-disk .bar{height:6px;background:var(--line);border-radius:4px;overflow:hidden} .fm-disk .bar i{display:block;height:100%;background:var(--disk);border-radius:4px} /* principal */ .fm-main{background:var(--card);border:1px solid var(--line);border-radius:var(--r);display:flex;flex-direction:column;min-width:0;min-height:0;position:relative;overflow:hidden} .fm-top{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line)} .fm-nav{display:flex;gap:2px} .ib{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:32px;min-width:32px;padding:0 9px;border-radius:8px;border:1px solid transparent;background:none;color:var(--mut);font:500 13px system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,sans-serif;cursor:pointer;white-space:nowrap} .ib:hover:not(:disabled){background:var(--bg2);color:var(--tx);border-color:var(--line)} .ib:disabled{opacity:.35;cursor:default} .ib.pri{background:var(--pri);color:#fff} .ib.pri:hover:not(:disabled){filter:brightness(1.1);background:var(--pri);color:#fff} .ib.danger:hover:not(:disabled){color:#d93a2e;background:#ef44441a;border-color:#ef444444} .ib.on{background:var(--line2);color:var(--tx)} .ib .cnt{background:var(--pri);color:#fff;border-radius:999px;font-size:11px;padding:0 6px;line-height:16px} .fm-crumbs{flex:1;min-width:0;display:flex;align-items:center;height:34px;background:var(--bg2);border:1px solid var(--line);border-radius:9px;padding:0 6px;overflow:hidden;cursor:text} .fm-crumbs .cr{display:flex;align-items:center;min-width:0;overflow-x:auto;scrollbar-width:none;white-space:nowrap} .fm-crumbs .cr::-webkit-scrollbar{display:none} .fm-crumbs .seg{padding:3px 7px;border-radius:6px;color:var(--mut);font-size:13px;cursor:pointer;border:1px solid transparent} .fm-crumbs .seg:hover{background:var(--line);color:var(--tx)} .fm-crumbs .seg.last{color:var(--tx);font-weight:600} .fm-crumbs .seg.drop{border-color:var(--cpu);background:#5b9cff22} .fm-crumbs .sl{color:var(--dim);font-size:12px} .fm-crumbs input{flex:1;background:none;border:0;outline:none;color:var(--tx);font:13px var(--mono);height:100%;padding:0 6px} .fm-search{position:relative;width:230px;flex:none} .fm-search svg{position:absolute;left:10px;top:9px;color:var(--dim)} .fm-search input{width:100%;height:34px;background:var(--bg2);border:1px solid var(--line);border-radius:9px;color:var(--tx);padding:0 10px 0 32px;font-size:13px;outline:none} .fm-search input:focus,.fm-crumbs:focus-within{border-color:var(--cpu);box-shadow:0 0 0 3px #5b9cff22} .fm-tools{display:flex;align-items:center;gap:2px;padding:6px 10px;border-bottom:1px solid var(--line);overflow-x:auto;scrollbar-width:thin} .fm-tools .sep{width:1px;height:20px;background:var(--line);margin:0 6px;flex:none} .fm-tools .sp{flex:1} /* lista */ .fm-body{flex:1;overflow:auto;position:relative;min-height:0;outline:none} .fm-load{position:absolute;top:0;left:0;height:2px;width:0;background:var(--cpu);z-index:3;transition:width .3s} .fm-load.on{width:70%;transition:width 4s cubic-bezier(.1,.7,.2,1)} .fm-head,.fm-row{display:grid;grid-template-columns:34px minmax(180px,1fr) 100px 150px 120px 140px;align-items:center} .fm-head{position:sticky;top:0;z-index:2;background:var(--card);border-bottom:1px solid var(--line);font-size:11.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--dim);height:34px} .fm-head span{padding:0 10px;cursor:pointer;user-select:none;display:flex;align-items:center;gap:4px;height:100%} .fm-head span:hover{color:var(--mut)} .fm-head span.on{color:var(--tx)} .fm-head .r,.fm-row .r{justify-content:flex-end;text-align:right} .fm-row{height:38px;border-bottom:1px solid #e9ecef;font-size:13.5px;cursor:default;user-select:none} .fm-row>span{padding:0 10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fm-row>span.c-chk,.fm-head>span:first-child{padding:0 0 0 10px;overflow:visible;text-overflow:clip;cursor:pointer} .fm-row:hover{background:#f2f5f8} .fm-row.sel{background:#5b9cff1f} .fm-row.sel:hover{background:#5b9cff2a} .fm-row.cut{opacity:.45} .fm-row.drop{outline:2px solid var(--cpu);outline-offset:-2px;background:#5b9cff22} .fm-row .c-name{display:flex;align-items:center;gap:10px;min-width:0} .fm-row .nm{overflow:hidden;text-overflow:ellipsis} .fm-row .lk{color:var(--dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;flex:none;max-width:40%} .fm-row .c-size,.fm-row .c-date,.fm-row .c-own{color:var(--mut);font-size:12.5px} .fm-row .c-perm{font-family:var(--mono);font-size:12px;color:var(--mut)} .fm-row .c-perm b{color:var(--tx);font-weight:500;margin-right:6px} .fm-row .c-path{color:var(--dim);font-size:12px} .chk{width:16px;height:16px;border:1.5px solid var(--line2);border-radius:4px;display:inline-block;position:relative;cursor:pointer;vertical-align:middle} .sel .chk,.chk.on{background:var(--cpu);border-color:var(--cpu)} .sel .chk::after,.chk.on::after{content:"";position:absolute;left:4px;top:1px;width:4px;height:8px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg)} .chk.part{background:var(--cpu);border-color:var(--cpu)} .chk.part::after{content:"";position:absolute;left:3px;top:6px;width:7px;height:2px;background:#fff} .ico{width:22px;height:22px;flex:none;display:inline-flex;align-items:center;justify-content:center} .ico svg{width:100%;height:100%} /* grade */ .fm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:6px;padding:12px} .fm-tile{display:flex;flex-direction:column;align-items:center;gap:8px;padding:12px 6px 10px;border-radius:10px;border:1px solid transparent;cursor:default;user-select:none;text-align:center;position:relative} .fm-tile:hover{background:#f2f5f8} .fm-tile.sel{background:#5b9cff1f;border-color:#5b9cff55} .fm-tile.cut{opacity:.45} .fm-tile.drop{border-color:var(--cpu);background:#5b9cff22} .fm-tile .ico{width:56px;height:56px} .fm-tile .thumb{width:72px;height:56px;object-fit:cover;border-radius:6px;background:var(--bg2)} .fm-tile .nm{font-size:12.5px;line-height:1.3;word-break:break-word;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;max-width:100%} .fm-tile .meta{font-size:11px;color:var(--dim)} .fm-tile .chk{position:absolute;top:8px;left:8px;opacity:0} .fm-tile:hover .chk,.fm-tile.sel .chk{opacity:1} .fm-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--dim);padding:70px 20px;text-align:center} .fm-empty .ico{width:64px;height:64px;opacity:.5} .fm-empty b{color:var(--mut);font-size:15px} .fm-err{margin:20px;padding:14px 16px;border-radius:10px;background:#ef44441a;border:1px solid #ef444444;color:#d93a2e;font-size:13.5px} .fm-status{display:flex;align-items:center;gap:14px;padding:8px 14px;border-top:1px solid var(--line);font-size:12.5px;color:var(--mut);min-height:38px;flex-wrap:wrap} .fm-status .sp{flex:1} .fm-status .clip{display:flex;align-items:center;gap:8px;background:var(--bg2);border:1px solid var(--line);border-radius:999px;padding:2px 4px 2px 12px} .fm-status .clip button{background:none;border:0;color:var(--cpu);cursor:pointer;font-size:12.5px;padding:2px 8px;border-radius:999px} .fm-status .clip button:hover{background:var(--line)} .fm-status .srch{color:#2f8cdc} /* arrastar e soltar */ .fm-drop{position:absolute;inset:8px;border:2px dashed var(--cpu);border-radius:12px;background:#ffffffd9;display:none;align-items:center;justify-content:center;flex-direction:column;gap:10px;z-index:6;color:#1f6fb5;font-size:15px;pointer-events:none} .fm-drop.on{display:flex} .fm-drop svg.i{width:40px;height:40px;color:var(--cpu)} .fm-drop small{color:var(--mut)} /* menu de contexto */ .fm-menu{position:fixed;z-index:90;min-width:220px;background:#ffffff;border:1px solid var(--line2);border-radius:10px;padding:5px;box-shadow:0 18px 50px #0000002e;font-size:13.5px} .fm-menu button{display:flex;align-items:center;gap:10px;width:100%;padding:7px 10px;background:none;border:0;color:var(--tx);border-radius:6px;cursor:pointer;text-align:left;font:inherit} .fm-menu button:hover:not(:disabled){background:#5b9cff22} .fm-menu button:disabled{opacity:.35;cursor:default} .fm-menu button svg{color:var(--mut)} .fm-menu button kbd{margin-left:auto;color:var(--dim);font:11px var(--mono)} .fm-menu button.danger{color:#d93a2e} .fm-menu button.danger svg{color:#d93a2e} .fm-menu hr{border:0;border-top:1px solid var(--line);margin:4px 2px} /* janelas */ .fm-ov{position:fixed;inset:0;z-index:95;background:#05070cb3;backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;padding:16px;animation:fmf .15s ease} @keyframes fmf{from{opacity:0}} .fm-dlg{width:100%;max-width:440px;background:#ffffff;border:1px solid var(--line2);border-radius:14px;box-shadow:0 30px 80px #00000030;display:flex;flex-direction:column;max-height:calc(100vh - 32px);animation:fmu .18s ease} @keyframes fmu{from{transform:translateY(8px);opacity:0}} .fm-dlg.wide{max-width:560px} .fm-dlg h4{font-size:16px;font-weight:600;padding:18px 20px 4px;display:flex;align-items:center;gap:10px} .fm-dlg .db{padding:10px 20px 6px;overflow:auto;font-size:13.5px;color:var(--mut);line-height:1.55} .fm-dlg .db b{color:var(--tx);font-weight:600} .fm-dlg .df{display:flex;justify-content:flex-end;gap:8px;padding:14px 20px 18px;flex-wrap:wrap} .fm-dlg .df button{height:36px;padding:0 16px;border-radius:9px;border:1px solid var(--line2);background:var(--bg2);color:var(--tx);font-size:13.5px;font-weight:500;cursor:pointer} .fm-dlg .df button:hover{border-color:var(--mut)} .fm-dlg .df button.pri{background:var(--pri);border-color:var(--pri);color:#fff} .fm-dlg .df button.pri:hover{filter:brightness(1.1)} .fm-dlg .df button.danger{background:var(--bad);border-color:var(--bad);color:#fff} .fm-dlg .df button:disabled{opacity:.6;cursor:wait} .fm-in{width:100%;height:38px;background:var(--bg2);border:1px solid var(--line2);border-radius:9px;color:var(--tx);padding:0 12px;font-size:14px;outline:none;margin-top:8px} .fm-in.mono{font-family:var(--mono);font-size:13px} .fm-in:focus{border-color:var(--cpu);box-shadow:0 0 0 3px #5b9cff22} .fm-dlg label.ck{display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer;color:var(--tx)} .fm-dlg label.ck input{accent-color:var(--cpu);width:16px;height:16px} .fm-names{margin-top:10px;max-height:150px;overflow:auto;background:var(--bg2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font:12.5px var(--mono);color:var(--tx)} .fm-names div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:2px 0} .perm-grid{display:grid;grid-template-columns:auto repeat(3,1fr);gap:8px 10px;align-items:center;margin-top:12px;background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:12px 14px} .perm-grid .h{font-size:11.5px;color:var(--dim);text-transform:uppercase;letter-spacing:.05em;text-align:center} .perm-grid .w{color:var(--tx);font-size:13px} .perm-grid label{display:flex;justify-content:center;cursor:pointer} .perm-grid input{accent-color:var(--cpu);width:17px;height:17px;cursor:pointer} .perm-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:4px} .perm-row div>span{font-size:12px;color:var(--dim)} .perm-rwx{font:13px var(--mono);color:var(--tx);margin-left:8px} .props{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;margin-top:6px} .props dt{color:var(--dim)} .props dd{color:var(--tx);word-break:break-all;font-size:13px} .props dd code{font-family:var(--mono);font-size:12.5px} .props .lnk{background:none;border:0;color:var(--cpu);cursor:pointer;font-size:12.5px;padding:0;margin-left:6px} /* upload */ .fm-up{position:fixed;right:18px;bottom:18px;z-index:55;width:340px;max-width:calc(100vw - 36px);background:#ffffff;border:1px solid var(--line2);border-radius:12px;box-shadow:0 20px 60px #0000002e;overflow:hidden} .fm-up .uh{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);font-size:13px;font-weight:600} .fm-up .uh span{flex:1} .fm-up .ul{max-height:240px;overflow:auto;padding:4px 0} .fm-up .ui{padding:7px 12px;font-size:12.5px} .fm-up .ui .t{display:flex;gap:8px;justify-content:space-between;margin-bottom:5px} .fm-up .ui .t b{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--tx)} .fm-up .ui .t small{color:var(--dim);flex:none} .fm-up .ui .t small.er{color:#d93a2e} .fm-up .ui .t small.ok{color:var(--ok)} .fm-up .ui .bar{height:4px;background:var(--line);border-radius:3px;overflow:hidden} .fm-up .ui .bar i{display:block;height:100%;width:0;background:var(--cpu);transition:width .2s} .fm-up .ui.done .bar i{background:var(--ok)} .fm-up .ui.err .bar i{background:var(--bad)} /* toasts */ .fm-toasts{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:100;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none} .fm-toast{background:#ffffff;border:1px solid var(--line2);color:var(--tx);padding:10px 16px;border-radius:10px;font-size:13.5px;box-shadow:0 12px 40px #00000026;animation:fmu .2s ease;max-width:min(560px,90vw);display:flex;gap:10px;align-items:center} .fm-toast.ok::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--ok);flex:none} .fm-toast.er{border-color:#ef444466;background:#fdecea;color:#b3261e} .fm-toast.er::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--bad);flex:none} /* editor */ .fm-ed{position:fixed;inset:0;z-index:80;background:#0a0e17;display:flex;flex-direction:column} .fm-ed .eh{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);background:#0f1522} .fm-ed .eh .ico{width:20px;height:20px} .fm-ed .ep{flex:1;min-width:0;font:13px var(--mono);color:var(--mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left} .fm-ed .ep b{color:var(--tx);font-weight:600} .fm-ed .dirty{width:9px;height:9px;border-radius:50%;background:var(--warn);display:none;flex:none} .fm-ed.is-dirty .dirty{display:block} .fm-ed .badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--mut)} .fm-ed .eb{flex:1;min-height:0;position:relative} .fm-ed .eb .CodeMirror{height:100%;font:13.5px/1.55 var(--mono)} .fm-ed textarea.plain{width:100%;height:100%;background:#0b1020;color:#dbe4f3;border:0;outline:none;resize:none;padding:14px 16px;font:13.5px/1.55 var(--mono);tab-size:4;white-space:pre;overflow:auto} .fm-ed textarea.plain.wrap{white-space:pre-wrap} .fm-ed .ef{display:flex;gap:16px;align-items:center;padding:5px 14px;border-top:1px solid var(--line);font:12px var(--mono);color:var(--dim);background:#0f1522} .fm-ed .ef .sp{flex:1} .fm-ed .eload{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--mut);font-size:14px} /* tema do CodeMirror */ .cm-s-lkm.CodeMirror{background:#0b1020;color:#dbe4f3} .cm-s-lkm .CodeMirror-gutters{background:#0b1020;border-right:1px solid #1a2336} .cm-s-lkm .CodeMirror-linenumber{color:#3f4b66} .cm-s-lkm .CodeMirror-cursor{border-left:2px solid #5b9cff} .cm-s-lkm div.CodeMirror-selected,.cm-s-lkm.CodeMirror-focused div.CodeMirror-selected{background:#28406f} .cm-s-lkm .CodeMirror-line::selection,.cm-s-lkm .CodeMirror-line>span::selection,.cm-s-lkm .CodeMirror-line>span>span::selection{background:#28406f} .cm-s-lkm .CodeMirror-activeline-background{background:#111a2e} .cm-s-lkm .CodeMirror-activeline-gutter .CodeMirror-linenumber{color:#8592ad} .cm-s-lkm .CodeMirror-matchingbracket{color:#fff!important;background:#2c3d63;outline:1px solid #5b9cff66} .cm-s-lkm .cm-keyword{color:#c792ea} .cm-s-lkm .cm-atom,.cm-s-lkm .cm-number{color:#f78c6c} .cm-s-lkm .cm-def{color:#82aaff} .cm-s-lkm .cm-variable{color:#dbe4f3} .cm-s-lkm .cm-variable-2{color:#f07178} .cm-s-lkm .cm-variable-3,.cm-s-lkm .cm-type{color:#ffcb6b} .cm-s-lkm .cm-property{color:#80cbc4} .cm-s-lkm .cm-operator{color:#89ddff} .cm-s-lkm .cm-comment{color:#5c6a88;font-style:italic} .cm-s-lkm .cm-string{color:#c3e88d} .cm-s-lkm .cm-string-2{color:#f07178} .cm-s-lkm .cm-meta,.cm-s-lkm .cm-qualifier{color:#ffcb6b} .cm-s-lkm .cm-builtin{color:#82aaff} .cm-s-lkm .cm-bracket{color:#89ddff} .cm-s-lkm .cm-tag{color:#f07178} .cm-s-lkm .cm-attribute{color:#ffcb6b} .cm-s-lkm .cm-header{color:#82aaff;font-weight:600} .cm-s-lkm .cm-link{color:#80cbc4;text-decoration:underline} .cm-s-lkm .cm-error{color:#ff5370} .cm-s-lkm .cm-searching{background:#ffcb6b44} .CodeMirror-dialog{background:#131b2c!important;color:var(--tx);border-bottom:1px solid var(--line2)!important;padding:8px 12px!important;font-size:13px} .CodeMirror-dialog input{background:var(--bg2);border:1px solid var(--line2)!important;border-radius:6px;color:var(--tx);padding:3px 8px;outline:none;font:13px var(--mono)} /* visualizador de imagem */ .fm-view{position:fixed;inset:0;z-index:80;background:#05070cf2;display:flex;flex-direction:column} .fm-view .vh{display:flex;align-items:center;gap:10px;padding:10px 14px;color:var(--tx);font-size:14px} .fm-view .vh span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fm-view .vb{flex:1;display:flex;align-items:center;justify-content:center;min-height:0;position:relative;padding:10px 60px 30px} .fm-view img{max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;background:repeating-conic-gradient(#1a2336 0 25%,#121a2a 0 50%) 0 0/20px 20px} .fm-view .nv{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;border-radius:50%;border:1px solid var(--line2);background:#131b2ccc;color:var(--tx);cursor:pointer;display:flex;align-items:center;justify-content:center} .fm-view .nv:hover{background:var(--line2)} .fm-view .nv.p{left:12px}.fm-view .nv.n{right:12px} @media (max-width:1500px){.fm-tools .lbl{display:none}} @media (max-width:900px){ #fm{height:calc(100vh - 76px)} .fm{grid-template-columns:1fr;grid-template-rows:auto minmax(0,1fr);gap:8px} .fm-side{flex-direction:row;padding:6px;overflow-x:auto;gap:4px} .fm-side h3,.fm-disk,.fm-place small{display:none} .fm-search{width:150px} .fm-head,.fm-row{grid-template-columns:34px minmax(140px,1fr) 84px 0 0 0} .fm-head span:nth-child(n+4),.fm-row>span:nth-child(n+4){display:none} } @media (max-width:560px){ .fm-top{flex-wrap:wrap} .fm-search{width:100%;order:3} .fm-row{height:44px} .fm-ed .eh .badge{display:none} } LKM_EOF_FMCSS cat > "$DIR/app.css" <<'LKM_EOF_APPCSS' /* LK Monitor - componentes das abas Sites e do app no celular */ .abtn{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:34px;padding:0 13px;border-radius:9px;border:1px solid var(--line2);background:var(--bg2);color:var(--tx);font:500 13px system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,sans-serif;cursor:pointer;text-decoration:none;white-space:nowrap} .abtn:hover{border-color:var(--mut)} .abtn.pri{background:var(--pri);border-color:var(--pri);color:#fff} .abtn.pri:hover{filter:brightness(1.1)} .abtn.danger{background:var(--bad);border-color:var(--bad);color:#fff} .abtn.sm{height:28px;padding:0 10px;font-size:12px;border-radius:7px} .abtn:disabled{opacity:.5;cursor:default} .abtn svg.i{width:15px;height:15px} .lnk{background:none;border:0;color:var(--cpu);cursor:pointer;font:inherit;font-size:12.5px;padding:0} .lnk:hover{text-decoration:underline} .mono{font-family:var(--mono)} code{font-family:var(--mono);font-size:.92em;background:var(--bg2);border:1px solid var(--line);border-radius:5px;padding:0 5px;color:var(--tx)} select.fm-in{appearance:none;-webkit-appearance:none;padding-right:30px;background-image:linear-gradient(45deg,transparent 50%,var(--mut) 50%),linear-gradient(135deg,var(--mut) 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px;background-repeat:no-repeat;cursor:pointer} .fm-in.sm{height:32px;margin-top:0;font-size:12.5px;width:auto} .seg{display:inline-flex;gap:3px;background:var(--bg2);border:1px solid var(--line);border-radius:9px;padding:3px;flex-wrap:wrap} .seg button{background:none;border:0;color:var(--mut);font:500 12.5px system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,sans-serif;padding:6px 11px;border-radius:6px;cursor:pointer} .seg button:hover{color:var(--tx)} .seg button.on{background:var(--line2);color:var(--tx)} .seg button[hidden]{display:none} /* sites */ #sites{display:grid;gap:16px} .st{padding:0;overflow:hidden} .st-top{display:flex;align-items:center;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap} .st-ttl{display:flex;align-items:baseline;gap:10px;flex:1;min-width:140px} .st-ttl h2{font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--mut)} .st-ttl span{font-size:12px;color:var(--dim)} .st-ttl{flex-wrap:wrap;row-gap:2px} .st-ttl h2{white-space:nowrap} .st-tools{display:flex;align-items:center;gap:8px} .st-search{position:relative;width:220px} .st-search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--dim)} .st-search input{width:100%;height:34px;background:var(--bg2);border:1px solid var(--line);border-radius:9px;color:var(--tx);padding:0 10px 0 32px;font-size:13px;outline:none} .st-search input:focus{border-color:var(--cpu);box-shadow:0 0 0 3px #5b9cff22} .st-ro{display:flex;gap:12px;align-items:flex-start;margin:14px 16px 0;padding:12px 14px;border-radius:10px;background:#f5b3011a;border:1px solid #f5b30144;color:#8a5a00;font-size:13px;line-height:1.5} .st-ro svg{color:var(--warn);margin-top:2px} .st-ro b{color:#6b4500} .st-head,.st-row{display:grid;grid-template-columns:minmax(220px,1.7fr) minmax(170px,1fr) 100px 330px;align-items:center;gap:12px;padding:0 16px} .st-head{height:34px;font-size:11.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--dim);border-bottom:1px solid var(--line)} .st-head .r,.st-row .r{text-align:right} .st-head span:last-child{text-align:left;padding-left:8px} .st-row{min-height:62px;border-bottom:1px solid #e9ecef;padding-top:8px;padding-bottom:8px} .st-row:last-child{border-bottom:0} .st-row:hover{background:#f5f7f9} .st-site{display:flex;align-items:center;gap:12px;min-width:0} .st-dot{width:9px;height:9px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px #22c55e88;flex:none} .st-row.off .st-dot{background:var(--dim);box-shadow:none} .st-dn{min-width:0;display:flex;flex-direction:column;gap:2px} .st-dn a{color:var(--tx);font-weight:600;font-size:14px;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .st-dn a:hover{color:#2f8cdc;text-decoration:underline} .st-row.off .st-dn a{color:var(--mut)} .st-dn small{color:var(--dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .st-dn small .mono{font-size:11.5px} .st-dn em{color:var(--warn);font-style:normal} .st-tags{display:flex;gap:6px;flex-wrap:wrap} .tag{font-size:11.5px;padding:2px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--mut);background:var(--bg2);white-space:nowrap;max-width:160px;overflow:hidden;text-overflow:ellipsis} .tag.t-laravel{color:#d4512f;border-color:#ff6b5a55;background:#ff6b5a14} .tag.t-php{color:#5865d6;border-color:#8892f655;background:#8892f614} .tag.t-proxy{color:#0f8a66;border-color:#2fd6a055;background:#2fd6a014} .tag.t-ssl{color:#1b8a31;border-color:#22c55e55;background:#22c55e14} .st-size{font-size:12.5px;color:var(--mut)} .st-dim{color:var(--dim)} .st-acts{display:flex;align-items:center;gap:2px;justify-content:flex-start} .sw{display:inline-flex;align-items:center;gap:8px;background:none;border:1px solid transparent;border-radius:8px;color:var(--mut);font:500 12.5px system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,sans-serif;cursor:pointer;padding:4px 8px;margin-right:4px;min-width:104px} .sw i{width:30px;height:17px;border-radius:999px;background:var(--line2);position:relative;transition:background .2s;flex:none} .sw i::after{content:"";position:absolute;left:2px;top:2px;width:13px;height:13px;border-radius:50%;background:#ffffff;transition:left .2s} .sw.on{color:#1b8a31} .sw.on i{background:var(--ok)} .sw.on i::after{left:15px;background:#fff} .sw:hover:not(:disabled){background:var(--bg2);border-color:var(--line)} .sw:disabled{cursor:default;opacity:.7} .st-empty{display:flex;flex-direction:column;align-items:center;gap:10px;padding:56px 20px;color:var(--dim);text-align:center;font-size:13.5px} .st-empty svg.i{width:40px;height:40px;opacity:.5} .st-empty b{color:var(--mut);font-size:15px} /* diálogos de site */ .fm-dlg.nfd{max-width:500px} .fm-dlg.xl{max-width:1100px;height:calc(100vh - 32px)} .fm-dlg.xl .db{flex:1;min-height:0;display:flex;flex-direction:column} .nf p{margin-bottom:10px} .nf-l{color:var(--tx);font-size:13px;font-weight:600;margin-top:14px;display:flex;align-items:center;gap:8px} .nf-l:first-child{margin-top:4px} .nf-h{font-size:12.5px;color:var(--dim);margin-top:6px} .nf-h.bad{color:#d93a2e} .nf .seg{margin-top:8px} .nf-err{color:#d93a2e;font-size:13px;margin-top:10px} .nf-err:empty{display:none} .nf-pre{margin-top:8px;max-height:180px;overflow:auto;background:#05070c;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:12px/1.5 var(--mono);color:#ffb4ab;white-space:pre-wrap;word-break:break-word} .nf-box{margin-top:16px;padding:12px 14px;border:1px solid var(--line2);border-radius:10px;background:var(--bg2)} .nf-box .nf-l:first-child{margin-top:0} .nf-warn{display:flex;align-items:center;gap:8px;margin:8px 0 4px;color:var(--warn);font-size:12.5px} .cpl{display:flex;align-items:center;gap:8px;margin-top:8px;background:#05070c;border:1px solid var(--line2);border-radius:9px;padding:6px 6px 6px 12px} .cpl code{flex:1;min-width:0;background:none;border:0;padding:0;font-size:13px;overflow-x:auto;white-space:nowrap;color:#7ee787} .cpl.col{flex-direction:column;align-items:stretch;padding:10px 12px} .cpl pre{font:12.5px/1.6 var(--mono);color:#7ee787;white-space:pre-wrap;word-break:break-all} .cpl.col .abtn{align-self:flex-end} /* visualizador de logs */ .lv{display:flex;flex-direction:column;gap:10px;flex:1;min-height:0} .lv-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .lv-bar .st-search{flex:1;min-width:150px;width:auto} .lv-bar label.ck{margin-top:0} .lv-file{font-size:12px;color:var(--dim);min-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .lv-out{flex:1;min-height:200px;overflow:auto;margin:0;background:#05070c;border:1px solid var(--line);border-radius:10px;padding:10px 12px;font:12.5px/1.5 var(--mono);color:#cfd7e6;white-space:pre-wrap;word-break:break-word} .lv-out .e{color:#ff8a80} .lv-out .w{color:#ffd479} .lv-out mark{background:#f5b30155;color:#fff;border-radius:2px} /* instalar no celular */ .pw p{margin-bottom:10px} .pw-step{display:flex;gap:12px;margin-top:14px} .pw-step>b{flex:none;width:24px;height:24px;border-radius:50%;background:#5b9cff22;border:1px solid #5b9cff55;color:#2f8cdc;display:flex;align-items:center;justify-content:center;font-size:12px} .pw-step>div{flex:1;min-width:0} .pw-t{color:var(--tx);font-weight:600;margin-bottom:6px} .pw-tabs{display:inline-flex;margin:2px 0 8px} .pw-p[hidden]{display:none} .pw-note{font-size:12.5px;color:var(--dim);margin-top:8px} .pw-urls>div{display:flex;align-items:center;gap:8px;justify-content:space-between;padding:4px 0} .pw-urls a{color:#2f8cdc;font-family:var(--mono);font-size:13px;word-break:break-all} /* deploy e tarefas em segundo plano */ .dp-opts{display:flex;flex-direction:column;gap:2px;margin-top:10px;border:1px solid var(--line);border-radius:10px;padding:4px;background:var(--bg2)} .dp-opt{display:flex;gap:10px;align-items:flex-start;padding:8px 10px;border-radius:7px;cursor:pointer} .dp-opt:hover{background:#f2f5f8} .dp-opt>input{accent-color:var(--cpu);width:16px;height:16px;margin-top:2px;flex:none} .dp-opt b{color:var(--tx);font-weight:600;font-family:var(--mono);font-size:13px} .dp-opt small{display:block;color:var(--dim);font-size:12px} .dp-opt .warn,.nf-h .warn{color:var(--warn)} .fm-dlg label.ck.sub{margin-top:6px;font-size:12.5px;color:var(--mut)} .dp-hist{display:flex;flex-direction:column;gap:4px;margin-top:8px;max-height:170px;overflow:auto} .dp-h{display:flex;align-items:center;gap:10px;background:var(--bg2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;color:var(--tx);font:13px system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,sans-serif;cursor:pointer;text-align:left} .dp-h:hover{border-color:var(--line2)} .dp-h small{color:var(--dim);margin-left:auto;font-size:12px} .jst{font-size:11.5px;font-weight:600;padding:2px 9px;border-radius:999px;border:1px solid var(--line2);color:var(--mut);white-space:nowrap} .jst.running,.jst.queued{color:#2f8cdc;border-color:#5b9cff66;background:#5b9cff14} .jst.ok{color:#1b8a31;border-color:#22c55e55;background:#22c55e14} .jst.error,.jst.interrupted{color:#d93a2e;border-color:#ef444466;background:#ef444414} .jv{display:flex;flex-direction:column;gap:10px;flex:1;min-height:0} .jv-head{display:flex;align-items:center;gap:10px;font-size:12.5px} .jv-res{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:9px;font-size:13.5px} .jv-res.ok{background:#22c55e14;border:1px solid #22c55e55;color:#1b7a2c} .jv-res.ok svg{color:var(--ok)} .jv-res.error,.jv-res.interrupted{background:#ef444414;border:1px solid #ef444455;color:#b3261e} .jv-res.error svg,.jv-res.interrupted svg{color:var(--bad)} .jv-res b{color:inherit} .jv-steps{display:flex;flex-wrap:wrap;gap:6px} .jv .lv-out{white-space:pre;word-break:normal;overflow-x:auto} .jstep{display:inline-flex;align-items:center;gap:7px;padding:4px 10px;border-radius:999px;border:1px solid var(--line);background:var(--bg2);font-size:12.5px;color:var(--mut)} .jstep small{color:var(--dim)} .jstep.running{color:#1f6fb5;border-color:#5b9cff66} .jstep.ok{color:var(--tx)}.jstep.ok svg{color:var(--ok)} .jstep.error{color:#d93a2e;border-color:#ef444466}.jstep.error svg{color:var(--bad)} .jstep.skipped,.jstep.canceled{opacity:.6} .jdot{width:8px;height:8px;border-radius:50%;border:1.5px solid var(--dim);display:inline-block} .jdot.sk{background:var(--dim)} .jspin{width:12px;height:12px;border-radius:50%;border:2px solid #5b9cff44;border-top-color:var(--cpu);display:inline-block;animation:jsp .8s linear infinite} @keyframes jsp{to{transform:rotate(360deg)}} /* bancos */ #bancos{display:grid;gap:16px} .db-row{grid-template-columns:minmax(220px,1.5fr) minmax(160px,1.3fr) 100px 190px} .db-ico{width:30px;height:30px;border-radius:8px;background:#5b9cff1a;color:#2f8cdc;display:flex;align-items:center;justify-content:center;flex:none} .db-ico.lite{background:#2fd6a01a;color:#0f8a66} .db-name{background:none;border:0;padding:0;color:var(--tx);font:600 14px system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,sans-serif;cursor:pointer;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .db-name:hover{color:#2f8cdc;text-decoration:underline} .st-dn b.mono{font-size:13.5px;color:var(--tx)} .tag svg.i{width:11px;height:11px;margin-right:4px;vertical-align:-1px} .db-row .ib .lbl{font-size:12.5px} .dbv{position:fixed;inset:0;z-index:70;background:#eef0f3;display:flex;flex-direction:column} .dbv-h{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);background:#f7f8fa;color:var(--mut)} .dbv-t{display:flex;flex-direction:column;min-width:0;flex:1} .dbv-t b{color:var(--tx);font-size:14.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .dbv-t small{color:var(--dim);font-size:12px;font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .dbv-sel{display:none;max-width:45%} .dbv-b{flex:1;min-height:0;display:grid;grid-template-columns:240px minmax(0,1fr)} .dbv-side{border-right:1px solid var(--line);padding:10px;display:flex;flex-direction:column;gap:8px;min-height:0;background:#f7f8fa} .dbv-side .st-search{width:auto} .dbv-tl{overflow:auto;display:flex;flex-direction:column;gap:1px;min-height:0} .dbv-ti{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;border:1px solid transparent;background:none;color:var(--mut);font:13px system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,sans-serif;cursor:pointer;text-align:left} .dbv-ti span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .dbv-ti small{color:var(--dim);font-size:11.5px} .dbv-ti:hover{background:var(--bg2);color:var(--tx)} .dbv-ti.on{background:#5b9cff1a;border-color:#5b9cff44;color:#1f6fb5} .dbv-main{display:flex;flex-direction:column;min-width:0;min-height:0;padding:10px 14px;gap:10px} .dbv-tabs{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:12.5px} .dbv-pane{display:flex;flex-direction:column;gap:10px;flex:1;min-height:0} .dbv-pane[hidden]{display:none} .dbv-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .dbv-bar .st-search{flex:1;min-width:160px;width:auto} .dbv-bar .sp{flex:1} .dbv-pg{display:flex;align-items:center;gap:4px;font-size:12.5px;color:var(--mut)} .dbv-grid{flex:1;min-height:0;overflow:auto;border:1px solid var(--line);border-radius:10px;background:var(--card)} .dbv-grid.busy{opacity:.6} .dbv-grid table{width:max-content;min-width:100%;font-size:12.5px} .dbv-grid th{position:sticky;top:0;background:#f2f4f6;padding:8px 10px;border-bottom:1px solid var(--line2);text-transform:none;letter-spacing:0;font-size:12px;color:var(--mut);font-family:var(--mono)} .dbv-grid th[data-sort]{cursor:pointer} .dbv-grid th[data-sort]:hover{color:var(--tx)} .dbv-grid td{padding:6px 10px;max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:var(--mono);font-size:12px;border-top:1px solid #e9ecef} .dbv-grid tr:hover td{background:#f5f7f9} .dbv-grid td.nul{color:var(--dim);font-style:italic} .dbv-sql{width:100%;min-height:130px;height:24vh;resize:vertical;background:#05070c;border:1px solid var(--line2);border-radius:10px;color:#dbe4f3;padding:10px 12px;font:13px/1.5 var(--mono);outline:none;tab-size:2} .dbv-sql:focus{border-color:var(--cpu);box-shadow:0 0 0 3px #5b9cff22} .writing .dbv-sql{border-color:#ef444488} .dbv-bar .abtn small{opacity:.7;font-size:11px} .sw.danger.on{color:#d93a2e}.sw.danger.on i{background:var(--bad)} .dbv-res{font-size:12.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap} .dbv-res.ok{color:#1b8a31}.dbv-res.warn{color:var(--warn)}.dbv-res.err .e{color:#d93a2e} /* backups */ .bk-auto{display:flex;align-items:center;gap:10px;margin:12px 0;padding:10px 12px;border-radius:9px;background:var(--bg2);border:1px solid var(--line);font-size:13px;color:var(--mut)} .bk-auto svg{color:var(--disk)} .bk-auto span{flex:1} .bk-list{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:10px;overflow:hidden;max-height:46vh;overflow-y:auto} .bk-row{display:grid;grid-template-columns:minmax(0,1fr) auto 80px auto;align-items:center;gap:10px;padding:8px 8px 8px 12px;border-top:1px solid #e9ecef} .bk-row:first-child{border-top:0} .bk-row:hover{background:#f5f7f9} .bk-main{display:flex;flex-direction:column;min-width:0} .bk-main b{color:var(--tx);font-size:13.5px} .bk-main small{color:var(--dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bk-size{font-size:12.5px;color:var(--mut);text-align:right} .bk-acts{display:flex;gap:2px} a.ib{text-decoration:none} /* config */ #config{display:grid;gap:16px} .cf-grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(min(100%,480px),1fr));align-items:start} .cf-card .ct h2{display:flex;align-items:center;gap:8px} .cf-card .ct h2 svg{color:var(--mut)} .cf-rule{display:flex;gap:12px;align-items:flex-start;padding:10px 0;border-top:1px solid var(--line)} .cf-rule:first-child{border-top:0;padding-top:0} .cf-rule .sw{margin:0;min-width:0;padding:2px 0;flex:none} .cf-rt{color:var(--tx);font-size:13.5px;line-height:2.2} .cf-rule small{display:block;color:var(--dim);font-size:12px;line-height:1.4} .cf-num{width:64px;text-align:center;margin:0 2px;padding:0 6px;display:inline-block;height:30px;font-family:var(--mono)} .fm-in.sm[type=time]{width:auto;display:inline-block;height:30px;margin:0 2px;color-scheme:dark} .cf-rep{padding-top:10px;border-top:1px solid var(--line);color:var(--mut);font-size:13px;line-height:2.2} .cf-ch{padding:12px 0;border-top:1px solid var(--line)} .cf-ch:first-child{border-top:0;padding-top:0} .cf-chh{display:flex;align-items:center;gap:10px;margin-bottom:6px} .cf-chh .sw{margin:0;min-width:0;padding:2px 0} .cf-chh b{flex:1;color:var(--tx);font-size:14px} .cf-f{margin-top:8px;display:flex;flex-direction:column} .cf-f label{font-size:12px;color:var(--mut)} .cf-f label small{color:var(--dim)} .cf-f .fm-in{margin-top:4px} .cf-row{display:flex;gap:10px;flex-wrap:wrap} .cf-ta{height:auto;padding:8px 12px;resize:vertical;line-height:1.5;font-size:12.5px} .cf-h{display:block;margin-top:8px;color:var(--dim);font-size:12px;line-height:1.5} .cf-alt{display:inline-block;margin-top:10px;font-size:12px} #wGowa[hidden],#wHook[hidden]{display:none} .wa-st{display:flex;align-items:center;flex-wrap:wrap;gap:6px 10px;margin:4px 0 2px;padding:9px 12px;border-radius:9px;background:var(--bg2);border:1px solid var(--line);font-size:13px;color:var(--mut)} .wa-st>i{width:9px;height:9px;border-radius:50%;background:var(--dim);flex:none} .wa-st b{color:var(--tx);font-weight:600} .wa-st a{color:#2f8cdc;font-size:12.5px} .wa-st .lnk{margin-left:auto;color:var(--mut);display:inline-flex} .wa-st small{flex-basis:100%;color:var(--dim);font-size:12px} .wa-st.ok{border-color:#22c55e55;background:#22c55e10}.wa-st.ok>i{background:var(--ok);box-shadow:0 0 8px #22c55e88}.wa-st.ok b{color:#1b8a31} .wa-st.er{border-color:#ef444455;background:#ef444410}.wa-st.er>i{background:var(--bad)}.wa-st.er b{color:#d93a2e} .cf-save{display:flex;align-items:center;justify-content:flex-end;gap:12px} .cf-save.in{margin-top:14px} .cf-dirty{color:var(--warn);font-size:12.5px}.cf-ok{color:var(--ok);font-size:12.5px} .cf-sites{display:flex;flex-wrap:wrap;gap:4px 18px} .cf-sites label.ck{display:flex;align-items:center;gap:8px;margin-top:6px;cursor:pointer;color:var(--tx);font-size:13px} .cf-sites input,.cf-hist input{accent-color:var(--cpu)} .cf-hist{max-height:420px;overflow:auto;display:flex;flex-direction:column} .cf-hi{display:flex;gap:10px;padding:8px 0;border-top:1px solid var(--line);font-size:13px;color:var(--tx)} .cf-hi:first-child{border-top:0} .cf-hi svg{flex:none;margin-top:2px} .cf-hi.alert svg,.cf-hi.repeat svg{color:var(--bad)}.cf-hi.ok svg{color:var(--ok)} .cf-hi small{color:var(--dim);font-size:11.5px} .cf-hi small .ok{color:#1b8a31}.cf-hi small .er{color:#d93a2e} @media (max-width:900px){ .db-row{grid-template-columns:minmax(0,1fr) auto} .db-row .ib .lbl{display:none} .dbv-b{grid-template-columns:1fr} .dbv-side{display:none} .dbv-sel{display:block} } @media (max-width:560px){ .st-tools{flex-wrap:wrap} .st-tools .seg{width:100%} .st-tools .seg button{flex:1} .dbv-main{padding:8px} .dbv-sel{max-width:50%} } @media (max-width:900px){ .st-head{display:none} .st-row{grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"site size" "tags tags" "acts acts";gap:8px 12px;padding:12px 14px} .st-site{grid-area:site}.st-tags{grid-area:tags}.st-size{grid-area:size}.st-acts{grid-area:acts;justify-content:flex-start;flex-wrap:wrap} .sw{margin-right:auto;padding-left:0} } @media (max-width:560px){ .st-top{padding:12px} .st-tools{width:100%} .st-search{flex:1;width:auto} .st-tools .abtn.pri span{display:none} .st-ro{margin:12px 12px 0} .fm-dlg.xl{height:calc(100vh - 16px)} .fm-ov{padding:8px} .lv-bar .seg{width:100%} .bk-row{grid-template-columns:minmax(0,1fr) auto;row-gap:4px} .bk-row .tag{justify-self:start} } @media (max-width:600px){#fm{height:calc(100vh - 122px)}} /* ------------------------------------------------------------ terminal completo (term.js) */ #v-terminal{align-content:start;grid-template-columns:minmax(0,1fr)} #v-terminal>*{min-width:0} #xt[hidden],#tSimple[hidden]{display:none!important} .tmode{font-size:11px;font-weight:500;color:var(--dim);text-transform:none;letter-spacing:0;margin-left:6px} #term .ct{gap:10px}#term .ct .ubadge{margin-left:auto} .xt{display:flex;flex-direction:column;min-width:0;width:100%;background:var(--card);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;min-height:260px;height:calc(100vh - 110px)} .xt.xt-full{position:fixed;left:0;right:0;top:0;z-index:40;border-radius:0;border:0;height:100vh} body.xt-noscroll{overflow:hidden} .xt-bar{display:flex;align-items:center;gap:2px;min-width:0;padding:6px 8px;border-bottom:1px solid var(--line);background:var(--bg2);min-height:44px} .xt-bar .sp{flex:1} .xt-bar .ib.on{background:var(--line2);color:var(--tx)} .xt-tabs{display:flex;gap:4px;min-width:0;overflow-x:auto;scrollbar-width:none} .xt-tabs::-webkit-scrollbar{display:none} .xt-tab{display:flex;align-items:center;gap:7px;height:30px;padding:0 4px 0 10px;border-radius:8px;border:1px solid transparent;color:var(--mut);font:12.5px var(--mono);cursor:pointer;white-space:nowrap;max-width:240px;flex:none;user-select:none} .xt-tab span{overflow:hidden;text-overflow:ellipsis} .xt-tab:hover{color:var(--tx);background:var(--card)} .xt-tab.cur{background:#05070c;border-color:var(--line2);color:var(--tx)} .xt-tab .d{width:7px;height:7px;border-radius:50%;flex:none;background:var(--dim)} .xt-tab .d.on{background:var(--ok);box-shadow:0 0 6px #22c55e88} .xt-tab .d.wait{background:var(--warn)} .xt-tab .d.off{background:var(--bad)} .xt-tab .x{width:22px;height:22px;border:0;background:none;color:var(--dim);border-radius:6px;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0} .xt-tab .x:hover{background:#ef444422;color:#ff8a80} .xt-tab .x svg{width:13px;height:13px} .xt-new{flex:none} .xt-wrap{flex:1;min-height:0;position:relative;background:#05070c} .xt-host{position:absolute;inset:0} .xt-term{position:absolute;inset:6px 4px 4px 10px;visibility:hidden} .xt-term.on{visibility:visible} .xt-term .xterm{height:100%} .xt-term .xterm-viewport{scrollbar-width:thin;scrollbar-color:#283654 transparent} .xt-term .xterm-viewport::-webkit-scrollbar{width:9px} .xt-term .xterm-viewport::-webkit-scrollbar-thumb{background:#283654;border-radius:6px} .xt-msg{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#05070cd9;z-index:3;padding:20px} .xt-msg[hidden]{display:none} .xt-mbox{background:var(--card);border:1px solid var(--line2);border-radius:12px;padding:18px 20px;max-width:420px;text-align:center;box-shadow:0 20px 60px #0009} .xt-mbox p{color:var(--tx);margin-bottom:14px;line-height:1.5} .xt-keys{display:none;gap:5px;min-width:0;padding:6px 8px;border-top:1px solid var(--line);background:var(--bg2);overflow-x:auto;scrollbar-width:none;flex:none} .xt-keys::-webkit-scrollbar{display:none} .xt-keys button{flex:none;min-width:40px;height:34px;padding:0 10px;border-radius:8px;border:1px solid var(--line2);background:var(--card);color:var(--tx);font:500 13px var(--mono);cursor:pointer;touch-action:manipulation} .xt-keys button:active{background:var(--line2)} .xt-keys button.on{background:var(--cpu);border-color:var(--cpu);color:#fff} .xt-foot{display:flex;align-items:center;gap:10px;padding:6px 12px;border-top:1px solid var(--line);font-size:12px;color:var(--dim);flex:none;min-width:0} .xt-foot .sp{flex:1} .xt-foot .ubadge{flex:none} .xt-hint{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} .xt-load,.xt-fail{max-width:560px;margin:40px auto;color:var(--mut)} .xt-fail b{display:block;color:var(--tx);margin-bottom:8px} .xt-fail p{margin-bottom:14px;line-height:1.5} .xt-paste{width:100%;min-height:150px;margin-top:10px;font:13px var(--mono);resize:vertical} @media (pointer:coarse){.xt-keys{display:flex}} @media (max-width:900px){.xt-bar .ib .lbl{display:none}.xt-hint{display:none}} @media (max-width:600px){.xt{border-radius:10px}.xt-bar{padding:5px 6px}.xt-tab{max-width:150px;padding-left:8px}.xt-term{inset:4px 2px 2px 6px}} /* ações do sistema (Painel) */ .sysact{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:14px;padding-top:14px;border-top:1px solid var(--line)} .sysact .abtn svg.i{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} .sysact .sy-off:hover:not(:disabled){border-color:var(--bad);color:#d93a2e} .sy-busy{font-size:12px;color:var(--warn)} .sy-pre{margin:0;max-height:260px;overflow:auto;background:#05070c;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:12px/1.5 var(--mono);color:#ffb4ab;white-space:pre-wrap;word-break:break-word} .sy-warn{margin-top:10px;padding:8px 10px;border-radius:8px;background:#f59e0b14;border:1px solid #f59e0b44;color:#9a6700;font-size:13px} .sy-note{margin-top:10px;color:var(--mut);font-size:12.5px} .sy-lbl{margin-top:14px;margin-bottom:6px;font-size:12px;color:var(--mut)} /* App Store (aba Apps) — tabela no estilo clássico */ #apps{display:grid;gap:14px} .ap{padding:0;overflow:hidden} .ap-tabs{display:flex;flex-wrap:wrap;border-bottom:1px solid var(--line);padding:0 8px} .ap-tabs button{background:none;border:0;border-bottom:2px solid transparent;padding:12px 12px 10px;cursor:pointer;color:var(--mut);font:inherit;font-size:13px;white-space:nowrap} .ap-tabs button:hover{color:var(--tx)} .ap-tabs button.on{color:var(--ok);border-bottom-color:var(--ok);font-weight:500} .ap-tool{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--line);font-size:12.5px;color:var(--mut);flex-wrap:wrap} .ap-tool .sp{flex:1} .ap-q{height:30px;border:1px solid var(--line2);border-radius:3px;padding:0 10px;font:inherit;font-size:13px;width:220px;max-width:100%;background:#fff;color:var(--tx);outline:none} .ap-q:focus{border-color:var(--ok)} #apMsg .st-ro,#apMsg .ap-busy{margin:12px 16px 0} #apMsg:not(:empty){padding-bottom:12px;border-bottom:1px solid var(--line)} .ap-busy{display:flex;align-items:center;gap:10px;padding:9px 14px;border-radius:3px;background:#ecf5fd;border:1px solid #a9cdee;font-size:13px} .ap-busy span{flex:1} .ap-tw{overflow-x:auto} .ap-t{width:100%;border-collapse:collapse;font-size:13px} .ap-t th{background:var(--bg2);color:var(--mut);font-weight:500;text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap;font-size:12.5px} .ap-t th:first-child,.ap-t td:first-child{padding-left:16px} .ap-t th:last-child,.ap-t td:last-child{padding-right:16px} .ap-t td{padding:10px 12px;border-top:1px solid var(--line);vertical-align:middle} .ap-t tbody tr:first-child td{border-top:0} .ap-t tbody tr:hover td{background:#fafbfc} .ap-t .r{text-align:right} .ap-empty{padding:28px!important;text-align:center;color:var(--mut)} .ap-n{display:flex;align-items:center;gap:10px;min-width:170px} .ap-n b{font-weight:500;display:block;white-space:nowrap} .ap-n small{display:block;color:var(--mut);font-size:11.5px;font-family:var(--mono)} .ap-logo{width:34px;height:34px;border-radius:6px;flex:none;box-shadow:0 0 0 1px #0000000f;object-fit:cover} .ap-logo.ini,.sw2 .ini{display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;font-size:13px} .ap-d{color:var(--mut);min-width:260px;line-height:1.45} .ap-note{margin-top:4px;font-size:12px;color:#1f6fb5} .ap-tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px} .ap-st{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--mut)} .ap-st b{width:7px;height:7px;border-radius:50%} .ap-st.on{color:var(--ok)}.ap-st.on b{background:var(--ok)} .ap-st.off{color:var(--bad)}.ap-st.off b{background:var(--bad)} .ap-st.ok{color:var(--ok)} .ap-st.run{color:var(--cpu)} .tag.ap-need{color:#9a6700;border-color:#f5cf8c;background:#fff6e5} .tag.ap-fail{color:var(--bad);border-color:#f3c0bb;background:#fdecea;cursor:pointer;font-family:inherit} .ap-acts{white-space:nowrap} .ap-acts .lnk{background:none;border:0;padding:0;color:var(--cpu);cursor:pointer;font:inherit;font-size:13px;text-decoration:none} .ap-acts .lnk:hover{text-decoration:underline} .ap-acts .lnk:disabled{color:var(--dim);cursor:default;text-decoration:none} .ap-sep{color:var(--line2);margin:0 7px} @media (max-width:600px){.ap-q{width:100%}.ap-d{min-width:200px}} /* áreas escuras (terminal, editor, visualizador): tokens do tema escuro */ .fm-ed,.fm-view,#xt,.xt-wrap,.term,.tin,.lv-out,.cpl,.nf-pre,.sy-pre{--bg:#0a0e17;--bg2:#0f1522;--card:#121a2a;--line:#1e2940;--line2:#283654;--tx:#e6ebf5;--mut:#8592ad;--dim:#56627d;color-scheme:dark} /* aba Internet (Cloudflare Tunnel) */ #internet{display:grid;gap:14px} .cf-step{display:flex;align-items:center;gap:14px;padding:12px 0;border-top:1px solid var(--line)} .cf-step:first-child{border-top:0;padding-top:0} .cf-n{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex:none;background:var(--bg2);border:1px solid var(--line2);color:var(--mut);font-size:13px} .cf-step.done .cf-n{background:var(--ok);border-color:var(--ok);color:#fff} .cf-n svg{width:15px;height:15px} .cf-sb{flex:1;min-width:0} .cf-st{font-weight:600} .cf-sd{color:var(--mut);font-size:13px} .cf-sa{flex:none} .cf-on{color:var(--ok);font-weight:500}.cf-off{color:var(--bad);font-weight:500} .cf-tip{display:flex;gap:8px;align-items:flex-start;margin-top:10px;padding:9px 12px;border-radius:3px;background:#ecf5fd;border:1px solid #a9cdee;font-size:12.5px;color:#1f5f99} .cf-tip svg{flex:none;width:15px;height:15px;margin-top:2px} .cf-form{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap;margin-bottom:12px} .cf-form label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--mut);min-width:0} .cf-form label.grow{flex:1;min-width:220px} .cf-form .fm-in{margin-top:0;height:34px;min-width:180px} .cf-form label.grow .fm-in{width:100%} #cfWarn .sy-warn,#cfWarn .cf-note{margin:0 0 12px} .cf-tw{overflow-x:auto;margin:0 -16px -16px;border-top:1px solid var(--line)} .cf-t{width:100%;border-collapse:collapse;font-size:13px} .cf-t th{background:var(--bg2);color:var(--mut);font-weight:500;text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);white-space:nowrap} .cf-t td{padding:9px 12px;border-top:1px solid var(--line);vertical-align:middle} .cf-t tbody tr:first-child td{border-top:0} .cf-t th:first-child,.cf-t td:first-child{padding-left:16px}.cf-t th:last-child,.cf-t td:last-child{padding-right:16px} .cf-t .r{text-align:right} .cf-t small{display:block;color:var(--dim);font-size:11.5px} .cf-host{color:var(--cpu);text-decoration:none;font-weight:500;word-break:break-all}.cf-host:hover{text-decoration:underline} .cf-acts{white-space:nowrap} .cf-acts .lnk,.cf-blk .lnk{background:none;border:0;padding:0;color:var(--cpu);cursor:pointer;font:inherit;font-size:13px} .cf-acts .lnk.red{color:var(--bad)} .cf-acts .lnk:disabled{color:var(--dim);cursor:default} .cf-quick{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid #9ad3a6;background:#e9f6ec;border-radius:3px;margin-bottom:10px;flex-wrap:wrap} .cf-quick .grow{flex:1;min-width:0;display:flex;flex-direction:column} .cf-quick small{color:var(--mut);font-size:12px} .cf-note{font-size:12.5px;color:var(--mut)} .cf-blk{display:flex;align-items:center;gap:8px;padding:8px 12px;margin-bottom:10px;border-radius:3px;background:#fdecea;border:1px solid #f3c0bb;color:#b3261e;font-size:13px} .cf-blk span{flex:1}.cf-blk svg{width:15px;height:15px} .cf-ol{margin:0 0 14px;padding-left:20px;display:grid;gap:4px} .cf-big{width:100%;height:42px;font-size:14px;margin-bottom:12px} .cf-wait{display:flex;align-items:center;gap:10px;margin-bottom:10px;font-size:13px} .cf-ok svg{color:var(--ok)} @media (max-width:600px){.cf-step{flex-wrap:wrap}.cf-sa{width:100%;padding-left:42px}} .cf-form label[hidden]{display:none} #cfBlocked:empty{display:none} /* 2FA */ .tfa-on,.tfa-off{display:flex;align-items:center;gap:12px;flex-wrap:wrap} .tfa-on .grow,.tfa-off .grow{flex:1;min-width:240px;font-size:13px;color:var(--mut);line-height:1.5} .tfa-on .grow b{color:var(--ok)} .tfa-on .grow small{display:block;font-size:12px} .tfa-on>svg{width:22px;height:22px;color:var(--ok);flex:none} .tfa-qr{display:flex;justify-content:center;margin:4px 0 10px;min-height:120px;align-items:center} .tfa-qr svg{width:200px;height:200px;max-width:100%;background:#fff;border:1px solid var(--line);border-radius:4px} .tfa-key{display:flex;align-items:center;justify-content:center;gap:8px;margin:6px 0 14px;flex-wrap:wrap} .tfa-key code{font-size:14px;padding:4px 8px;word-break:break-all} .pw-l{display:block;font-size:12.5px;color:var(--mut);margin-bottom:10px}.pw-l input{width:100%;margin-top:4px} .pw-c{display:flex;gap:8px;align-items:flex-start;font-size:13px;margin:4px 0 6px;line-height:1.45} .tfa-in{text-align:center;font:600 22px var(--mono)!important;letter-spacing:.35em;height:46px!important} .tfa-codes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0} .tfa-codes code{font-size:15px;padding:8px;text-align:center} LKM_EOF_APPCSS cat > "$DIR/sites.js" <<'LKM_EOF_SITESJS' /* LK Monitor - aba Sites (VirtualHosts do Apache) */ (function () { 'use strict'; const U = window.UI; if (!U) return; const { E, qs, qsa, toast, fail, dialog, confirmDlg, menu, svg, copyText, fsize } = U; const C0 = typeof CFG !== 'undefined' ? CFG : {}; const TOKEN = C0.token || ''; const RW = C0.user === 'root'; const TYPE = { php: 'PHP', laravel: 'Laravel', proxy: 'Proxy' }; const S = { sites: [], php: [], mysql: false, ip: '', loaded: false, error: '', filter: '', sizes: {}, busy: false }; let root, inited = false; async function api(act, body) { let r; try { r = await fetch('?sites=' + act, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-LKM-Token': TOKEN }, body: JSON.stringify(body || {}) }); } catch (e) { throw new Error('Sem conexão com o painel'); } if (r.status === 401) { location.reload(); throw new Error('Sessão expirada'); } let d; try { d = await r.json(); } catch (e) { throw new Error('Resposta inválida do servidor (' + r.status + ')'); } if (!d.ok) { const er = new Error(d.error || 'Erro desconhecido'); er.detail = d.detail || ''; er.data = d; throw er; } return d; } const errHtml = e => E(e.message) + (e.detail ? '
' + E(e.detail) + '
' : ''); const siteUrl = s => (s.ssl ? 'https://' : 'http://') + s.domain + '/'; const hostsLine = s => (S.ip || '127.0.0.1') + ' ' + s.domain + (s.aliases.includes('www.' + s.domain) ? ' www.' + s.domain : ''); /* ------------------------------------------------------------ montagem */ function build() { root = qs('#sites'); const why = C0.user ? 'o painel roda comandos como ' + E(C0.user) + ' (sem root)' : 'o terminal do painel está desativado'; root.innerHTML = '
' + '

Sites

' + '
' + '' + (RW ? '' : '') + '
' + (RW ? '' : '
' + svg('lock') + '
Somente leitura. Como ' + why + ', os sites aparecem só para consulta. Para criar, ligar/desligar e remover sites por aqui, rode sudo lk › opção 33 › 1 e escolha root no acesso do terminal.
') + '
SiteTipoTamanhoAções
' + '
'; root.addEventListener('click', onClick); qs('#stQ').addEventListener('input', e => { S.filter = e.target.value.trim().toLowerCase(); render(); }); } async function load(quiet) { if (S.busy) return; S.busy = true; if (!quiet && !S.loaded) qs('#stList').innerHTML = '
Carregando sites…
'; try { const d = await api('list'); Object.assign(S, { sites: d.sites, php: d.php, mysql: d.mysql, ip: d.ip, loaded: true, error: '' }); } catch (e) { S.error = e.message; S.loaded = true; } finally { S.busy = false; } render(); } function render() { const list = qs('#stList'); if (!list) return; if (S.error) { list.innerHTML = '
' + E(S.error) + '
'; qs('#stCount').textContent = ''; return; } const v = S.sites.filter(s => !S.filter || (s.domain + ' ' + s.aliases.join(' ') + ' ' + s.root + ' ' + s.type).toLowerCase().includes(S.filter)); const on = S.sites.filter(s => s.on).length; qs('#stCount').textContent = S.sites.length ? S.sites.length + (S.sites.length === 1 ? ' site' : ' sites') + ' · ' + on + ' ligado' + (on === 1 ? '' : 's') : ''; if (!S.sites.length) { list.innerHTML = '
' + svg('globe') + 'Nenhum site criado ainda' + (RW ? 'Crie o primeiro com o botão Novo site.' : '') + '
'; return; } if (!v.length) { list.innerHTML = '
Nenhum site corresponde ao filtro.
'; return; } list.innerHTML = v.map(rowHtml).join(''); } function rowHtml(s) { const al = s.aliases.filter(a => a !== s.domain); const size = S.sizes[s.name]; const sizeHtml = size === 'busy' ? 'calculando…' : size != null ? '' + fsize(size) + '' : ''; const tags = '' + TYPE[s.type] + '' + (s.php ? 'PHP ' + E(s.php) + '' : '') + (s.proxy ? '→ ' + E(s.proxy.replace(/^https?:\/\/(127\.0\.0\.1|localhost)/, '').replace(/\/$/, '')) + '' : '') + (s.ssl ? 'SSL' : ''); return '
' + '
' + '' + E(s.domain) + '' + '' + (al.length ? E(al.join(', ')) + ' · ' : '') + '' + E(s.root || '—') + '' + (s.exists || s.type === 'proxy' ? '' : ' (pasta não existe)') + '
' + '
' + tags + '
' + '
' + sizeHtml + '
' + '
' + '' + '' + '' + (RW && s.type === 'laravel' ? '' : '') + '' + (RW ? '' : '') + '' + '
'; } function siteOf(el) { const r = el.closest('[data-n]'); return r ? S.sites.find(s => s.name === r.dataset.n) : null; } function onClick(e) { const b = e.target.closest('[data-a]'); if (!b || b.disabled) return; const a = b.dataset.a, s = siteOf(b); if (a === 'reload') return load(); if (a === 'new') return newSite(); if (!s) return; if (a === 'toggle') return toggle(s); if (a === 'open') return window.open(siteUrl(s), '_blank', 'noopener'); if (a === 'folder') return openFolder(s); if (a === 'log') return viewLog(s); if (a === 'deploy') return deploy(s); if (a === 'backups') return backups(s); if (a === 'size') return calcSize(s); if (a === 'more') { const r = b.getBoundingClientRect(); return moreMenu(s, r.right - 230, r.bottom + 4); } } function moreMenu(s, x, y) { menu(Math.max(6, x), y, [ { l: 'Abrir site', i: 'open', fn: () => window.open(siteUrl(s), '_blank', 'noopener') }, { l: 'Abrir pasta', i: 'folder', dis: !s.root, fn: () => openFolder(s) }, { l: 'Ver logs', i: 'log', fn: () => viewLog(s) }, ...(s.type === 'laravel' ? [{ l: 'Deploy…', i: 'rocket', dis: !RW, fn: () => deploy(s) }] : []), { l: 'Backups…', i: 'archive', dis: !RW, fn: () => backups(s) }, { l: 'Calcular tamanho', i: 'info', fn: () => calcSize(s) }, { l: 'Copiar linha do arquivo hosts', i: 'copy', fn: () => copyText(hostsLine(s)).then(() => toast('Linha copiada: ' + hostsLine(s))) }, '-', { l: s.on ? 'Desligar site' : 'Ligar site', i: 'power', dis: !RW, fn: () => toggle(s) }, { l: 'Remover site…', i: 'trash', danger: true, dis: !RW, fn: () => removeSite(s) } ]); } /* ------------------------------------------------------------ ações */ function openFolder(s) { if (!C0.user) return toast('O gerenciador de arquivos está desativado nesta instalação.', 'er'); const p = s.exists ? s.root : '/var/www'; if (window.FM) window.FM.go(p); location.hash = '#arquivos'; } async function calcSize(s) { S.sizes[s.name] = 'busy'; render(); try { const d = await api('size', { name: s.name }); S.sizes[s.name] = d.size; } catch (e) { delete S.sizes[s.name]; fail(e); } render(); } async function toggle(s) { if (!RW) return; if (s.on && !(await confirmDlg('Desligar ' + s.domain + '?', 'O site para de responder, mas os arquivos continuam em ' + E(s.root) + '. Dá para ligar de novo a qualquer momento.', 'Desligar', true))) return; try { const d = await api('toggle', { name: s.name, on: !s.on }); Object.assign(s, d.site); toast(s.on ? 'Site ' + s.domain + ' ligado' : 'Site ' + s.domain + ' desligado'); } catch (e) { dialog({ title: 'Não foi possível alterar o site', icon: 'warn', html: errHtml(e), wide: !!e.detail }); } render(); } async function removeSite(s) { if (!RW) return; const r = await dialog({ title: 'Remover ' + s.domain, icon: 'trash', html: '

O site será desligado e a configuração dele no Apache será apagada' + (s.ssl ? ' (incluindo o SSL)' : '') + '.

' + (s.exists ? '' : '') + '
Para confirmar, digite ' + E(s.name) + '
' + '
', buttons: [{ label: 'Cancelar', value: null }, { label: 'Remover', value: 'ok', kind: 'danger' }], onOpen(ov) { const i = qs('#rmConf', ov), b = qs('.df button.danger', ov); const chk = () => { b.disabled = i.value.trim() !== s.name; }; i.addEventListener('input', chk); chk(); }, async validate(ov) { const conf = qs('#rmConf', ov).value.trim(); if (conf !== s.name) return false; const files = !!(qs('#rmFiles', ov) && qs('#rmFiles', ov).checked); const b = qs('.df button.danger', ov); b.disabled = true; b.textContent = 'Removendo…'; try { return { d: await api('remove', { name: s.name, confirm: conf, files }) }; } catch (e) { qs('#rmErr', ov).innerHTML = errHtml(e); b.disabled = false; b.textContent = 'Remover'; return false; } } }); if (!r || !r.d) return; toast('Site ' + s.domain + ' removido' + (r.d.notes && r.d.notes.length ? ' · ' + r.d.notes.join(' · ') : '')); S.sites = S.sites.filter(x => x !== s); render(); } /* ------------------------------------------------------------ novo site */ const normDom = v => v.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0].replace(/^www\./, ''); const validDom = d => d.length <= 200 && /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(d); async function newSite() { if (!RW) return; if (!S.loaded) await load(true); const phpOpts = S.php.slice().reverse().map(v => '').join(''); const r = await dialog({ title: 'Novo site', icon: 'globe', cls: 'nfd', html: '
' + '
Domínio
' + '
Para usar só na rede local, termine com .test (ex.: loja.test).
' + '
Tipo
' + '
' + '
Versão do PHP
' + (phpOpts ? '' : '
Nenhum PHP-FPM instalado. Instale o LAMP pelo lk (opção 1).
') + '
' + '' + '' + '' + '
', buttons: [{ label: 'Cancelar', value: null }, { label: 'Criar site', value: 'ok', kind: 'pri' }], onOpen(ov) { let type = 'php'; const dom = qs('#nfDom', ov), h = qs('#nfDomH', ov); const hint = () => { const d = normDom(dom.value) || 'dominio'; qs('#nfWwwD', ov).textContent = 'www.' + d; qs('#nfTypeH', ov).innerHTML = type === 'php' ? 'Arquivos em /var/www/' + E(d) + '/public_html' : type === 'laravel' ? 'Projeto em /var/www/' + E(d) + '; o site aponta para a pasta public/' : 'Encaminha o domínio para um app rodando em 127.0.0.1:porta'; const v = normDom(dom.value); h.classList.remove('bad'); if (!v) h.innerHTML = 'Para usar só na rede local, termine com .test (ex.: loja.test).'; else if (/^[a-z0-9-]+$/.test(v)) h.innerHTML = 'Falta a terminação. Sugestão: '; else if (!validDom(v)) { h.classList.add('bad'); h.textContent = 'Domínio inválido: use letras, números, hífen e pontos (ex.: loja.test).'; } else h.innerHTML = /\.(test|local|lan|localhost)$/.test(v) ? 'Domínio local: lembre de adicionar no arquivo hosts do seu PC (mostramos a linha depois de criar).' : 'Para funcionar na internet, o DNS de ' + E(v) + ' precisa apontar para este servidor.'; }; dom.addEventListener('input', hint); h.addEventListener('click', e => { const b = e.target.closest('[data-sug]'); if (b) { dom.value = b.dataset.sug; hint(); dom.focus(); } }); qsa('#nfType button', ov).forEach(b => b.onclick = () => { type = b.dataset.t; ov.dataset.type = type; qsa('#nfType button', ov).forEach(x => x.classList.toggle('on', x === b)); qs('#nfPhpW', ov).hidden = type === 'proxy'; qs('#nfPortW', ov).hidden = type !== 'proxy'; qs('#nfDbW', ov).hidden = type === 'proxy'; hint(); }); ov.dataset.type = 'php'; hint(); }, async validate(ov) { const err = qs('#nfErr', ov), type = ov.dataset.type || 'php'; const d = normDom(qs('#nfDom', ov).value); err.textContent = ''; if (!validDom(d)) { err.textContent = d ? 'Domínio inválido. Exemplo: meusite.test' : 'Digite o domínio do site.'; qs('#nfDom', ov).focus(); return false; } if (S.sites.some(s => s.name === d)) { err.textContent = 'Já existe um site ' + d + '.'; return false; } const body = { domain: d, type, www: qs('#nfWww', ov).checked }; if (type === 'proxy') { const p = +qs('#nfPort', ov).value; if (!(p >= 1 && p <= 65535)) { err.textContent = 'Informe a porta do app (ex.: 3000).'; qs('#nfPort', ov).focus(); return false; } body.port = p; } else { if (!qs('#nfPhp', ov)) { err.textContent = 'Nenhum PHP-FPM instalado.'; return false; } body.php = qs('#nfPhp', ov).value; body.db = qs('#nfDb', ov).checked; } const b = qs('.df button.pri', ov); b.disabled = true; b.textContent = 'Criando…'; try { return { d: await api('create', body), type }; } catch (e) { err.innerHTML = errHtml(e); b.disabled = false; b.textContent = 'Criar site'; return false; } } }); if (!r || !r.d) return; created(r.d, r.type); load(true); } function created(d, type) { const s = d.site, url = siteUrl(s); const env = d.db ? 'DB_CONNECTION=mysql\nDB_HOST=127.0.0.1\nDB_PORT=3306\nDB_DATABASE=' + d.db.name + '\nDB_USERNAME=' + d.db.user + '\nDB_PASSWORD=' + d.db.pass : ''; dialog({ title: 'Site criado', icon: 'check', wide: true, html: '
' + '

O site ' + E(s.domain) + ' está ligado. Endereço: ' + E(url) + '

' + (type === 'laravel' ? '

Coloque o projeto Laravel em ' + E(s.root) + ' (ex.: git clone … ' + E(s.root) + ') e depois corrija as permissões.

' : '') + '
Arquivo hosts do Windows
' + '
Para abrir pelo nome no seu PC, adicione esta linha em C:\\Windows\\System32\\drivers\\etc\\hosts (abra o Bloco de Notas como administrador):
' + '
' + E(d.hosts) + '
' + (d.db ? '
' + svg('db') + ' Banco de dados criado
' + '
' + svg('warn') + 'Anote agora: a senha não será mostrada de novo.
' + '
Banco
' + E(d.db.name) + '
Usuário
' + E(d.db.user) + '
Senha
' + E(d.db.pass) + '
Servidor
127.0.0.1:3306
' + '
Linhas para o .env
' + E(env) + '
' : '') + (d.db_error ? '
' + E(d.db_error) + '
' : '') + '
', buttons: [{ label: 'Abrir site', value: 'open' }, { label: 'Concluir', value: true, kind: 'pri' }], onOpen(ov) { qsa('[data-copy]', ov).forEach(b => b.onclick = () => copyText(qs('#' + b.dataset.copy, ov).textContent).then(() => toast('Copiado'))); } }).then(v => { if (v === 'open') window.open(url, '_blank', 'noopener'); }); } /* ------------------------------------------------------------ logs */ function viewLog(s) { let which = s.type === 'laravel' ? 'laravel' : 'error', timer = null, alive = true, reqId = 0; const LBL = { error: 'Erros do Apache', access: 'Acessos', laravel: 'Laravel' }; const hl = (txt, q) => { const lines = txt.split('\n'); return lines.map(l => { let h = E(l); if (q) { const re = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); h = h.replace(re, m => '' + m + ''); } const c = /\b(error|critical|emergency|alert|fatal|emerg|crit)\b/i.test(l) ? 'e' : /\b(warn|warning|notice)\b/i.test(l) ? 'w' : ''; return c ? '' + h + '' : h; }).join('\n'); }; dialog({ title: 'Logs · ' + s.domain, icon: 'log', cls: 'xl', html: '
' + '
' + Object.keys(LBL).map(k => '').join('') + '
' + '' + '' + '' + '
' + '
Carregando…
', buttons: [{ label: 'Fechar', value: null }], onOpen(ov) { const out = qs('#lvO', ov), q = qs('#lvQ', ov), n = qs('#lvN', ov), live = qs('#lvLive', ov); const fetchLog = async () => { const id = ++reqId, query = q.value.trim(); const stick = out.scrollTop + out.clientHeight >= out.scrollHeight - 30; try { const d = await api('logs', { name: s.name, which, lines: +n.value, q: query }); if (id !== reqId || !alive) return; qsa('#lvW button', ov).forEach(b => { b.hidden = !(d.avail || []).includes(b.dataset.w); b.classList.toggle('on', b.dataset.w === which); }); qs('#lvF', ov).innerHTML = '' + E(d.file) + '' + (d.size != null ? ' · ' + fsize(d.size) : '') + (query ? ' · ' + d.matches + ' linha(s) com “' + E(query) + '”' : ''); out.innerHTML = d.missing ? 'O arquivo ainda não existe (nenhum registro até agora).' : d.text ? hl(d.text, query) : '' + (query ? 'Nada encontrado.' : 'Log vazio.') + ''; if (stick || id === 1) out.scrollTop = out.scrollHeight; } catch (e) { if (id !== reqId || !alive) return; if (e.data && e.data.avail) { qsa('#lvW button', ov).forEach(b => { b.hidden = !e.data.avail.includes(b.dataset.w); }); if (e.data.avail.length && !e.data.avail.includes(which)) { which = e.data.avail[0]; return fetchLog(); } } qs('#lvF', ov).textContent = ''; out.innerHTML = '' + E(e.message) + ''; } }; const setLive = () => { clearInterval(timer); timer = live.checked ? setInterval(() => { if (!document.hidden) fetchLog(); }, 3000) : null; }; qsa('#lvW button', ov).forEach(b => b.onclick = () => { which = b.dataset.w; out.textContent = 'Carregando…'; reqId++; fetchLog(); }); let qt; q.addEventListener('input', () => { clearTimeout(qt); qt = setTimeout(fetchLog, 350); }); q.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); fetchLog(); } }); n.onchange = fetchLog; live.onchange = setLive; qs('#lvR', ov).onclick = fetchLog; fetchLog(); } }).then(() => { alive = false; clearInterval(timer); }); } /* ------------------------------------------------------------ tarefas em segundo plano */ async function jget(q) { let r; try { r = await fetch('?jobs=' + q, { credentials: 'same-origin', cache: 'no-store' }); } catch (e) { throw new Error('Sem conexão com o painel'); } if (r.status === 401) { location.reload(); throw new Error('Sessão expirada'); } const d = await r.json(); if (!d.ok) throw new Error(d.error || 'Erro'); return d; } const ST = { queued: 'Na fila', running: 'Em andamento', ok: 'Concluído', error: 'Falhou', interrupted: 'Interrompido' }; const fdt = t => { if (!t) return '—'; const d = new Date(t * 1000), p = n => String(n).padStart(2, '0'); return p(d.getDate()) + '/' + p(d.getMonth() + 1) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); }; const fdur = s => s < 60 ? s + ' s' : Math.floor(s / 60) + ' min ' + (s % 60) + ' s'; const jobBadge = J => '' + (ST[J.state] || J.state) + ''; /** Acompanha uma tarefa: passos, log ao vivo e resultado final. */ function jobView(id, title, icon) { let from = 0, timer = null, alive = true, J = null; const ico = { pending: '', running: '', ok: svg('check'), error: svg('x'), skipped: '', canceled: '' }; return dialog({ title: title || 'Tarefa', icon: icon || 'rocket', cls: 'xl', html: '
' + '
', buttons: [{ label: 'Baixar log', value: 'log' }, { label: 'Fechar', value: null, kind: 'pri' }], onOpen(ov) { const L = qs('#jvL', ov); const tick = async () => { if (!alive) return; try { const d = await jget('status&id=' + encodeURIComponent(id) + '&from=' + from); J = d.job; if (d.chunk) { const stick = L.scrollTop + L.clientHeight >= L.scrollHeight - 30; if (!from || d.from < from) L.textContent = ''; L.textContent += d.chunk; if (stick || !from) L.scrollTop = L.scrollHeight; } from = d.size; qs('#jvS', ov).innerHTML = jobBadge(J); const t0 = J.started || J.created, t1 = J.ended || Math.floor(Date.now() / 1000); qs('#jvT', ov).textContent = 'iniciado ' + fdt(J.created) + ' · ' + fdur(Math.max(0, t1 - t0)); qs('#jvP', ov).innerHTML = (J.steps || []).map(st => '
' + (ico[st.state] || '') + '' + E(st.label) + '' + (st.ended && st.started ? '' + fdur(st.ended - st.started) + '' : st.state === 'skipped' ? 'ignorado' : '') + '
').join(''); if (['ok', 'error', 'interrupted'].includes(J.state)) { const R = qs('#jvR', ov); R.hidden = false; R.className = 'jv-res ' + J.state; R.innerHTML = J.state === 'ok' ? svg('check') + 'Concluído com sucesso' + (J.ended ? ' em ' + fdur(J.ended - J.started) : '') + '' : J.state === 'error' ? svg('warn') + 'Falhou no passo ' + E(J.failed || '?') + '. Veja o log abaixo.' : svg('warn') + 'Interrompido: a tarefa parou sem terminar (servidor reiniciado?).'; return; } } catch (e) { qs('#jvT', ov).textContent = e.message; } timer = setTimeout(tick, 1000); }; tick(); } }).then(v => { alive = false; clearTimeout(timer); if (v === 'log') { location.href = '?jobs=log&id=' + encodeURIComponent(id); return jobView(id, title, icon); } if (J && ['queued', 'running'].includes(J.state)) toast('A tarefa continua em segundo plano'); return J; }); } window.JOBS = { view: jobView, get: jget, badge: jobBadge, fdt, fdur }; /* ------------------------------------------------------------ backups */ const KIND = { manual: 'manual', auto: 'automático', seguranca: 'antes de restaurar' }; const fdtY = t => { const d = new Date(t * 1000), p = n => String(n).padStart(2, '0'); return p(d.getDate()) + '/' + p(d.getMonth() + 1) + '/' + d.getFullYear() + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); }; const dbLabel = db => !db || !db.type ? 'sem banco (só arquivos)' : db.type === 'mysql' ? 'MySQL · ' + db.name : 'SQLite · ' + db.path; async function backups(s) { let d; try { d = await api('backups', { name: s.name }); } catch (e) { return fail(e); } if (d.running) { const ttl = (d.running_type === 'restore' ? 'Restaurar · ' : d.running_type === 'deploy' ? 'Deploy · ' : 'Backup · ') + s.domain; await jobView(d.running, ttl, d.running_type === 'deploy' ? 'rocket' : 'archive'); return backups(s); } const a = d.auto; const list = d.backups.length ? '
' + d.backups.map(m => '
' + '
' + fdtY(m.created) + '' + E(dbLabel(m.db)) + '
' + '' + (KIND[m.kind] || m.kind) + '' + '' + fsize(m.size) + '' + '
' + svg('download') + '' + (RW ? '' : '') + '
').join('') + '
' : '
' + svg('archive') + 'Nenhum backup aindaFaça o primeiro com o botão abaixo.
'; const r = await dialog({ title: 'Backups · ' + s.domain, icon: 'archive', wide: true, html: '

Banco: ' + E(dbLabel(d.db)) + ' · pasta ' + E(d.root) + '. Ficam fora do arquivo: vendor, node_modules, caches, sessões e logs.

' + '
' + svg('archive') + 'Backup automático: ' + (a.enabled ? 'diário às ' + E(a.time) + ', mantém os últimos ' + a.keep + ' · este site ' + (a.on ? 'está incluído' : 'não está incluído') + '' : 'desativado') + '
' + list + '
', buttons: [{ label: 'Fechar', value: null }].concat(RW ? [{ label: 'Fazer backup agora', value: 'now', kind: 'pri' }] : []), onOpen(ov, close) { qsa('[data-b]', ov).forEach(b => b.onclick = () => { const f = b.closest('[data-f]'); close({ act: b.dataset.b, file: f ? f.dataset.f : null }); }); } }); if (!r) return; if (r === 'now' || r.act === 'now') { try { const j = await api('backup', { name: s.name }); await jobView(j.job, 'Backup · ' + s.domain, 'archive'); } catch (e) { if (e.data && e.data.running) await jobView(e.data.running, 'Tarefa · ' + s.domain); else fail(e); } return backups(s); } if (r.act === 'cfg') { location.hash = '#config'; return; } const m = d.backups.find(x => x.file === r.file); if (!m) return; if (r.act === 'del') { if (await confirmDlg('Apagar backup?', 'O backup de ' + fdtY(m.created) + ' (' + fsize(m.size) + ') será apagado de vez.', 'Apagar', true)) { try { await api('backup_delete', { name: s.name, file: m.file }); toast('Backup apagado'); } catch (e) { fail(e); } } return backups(s); } if (r.act === 'restore') { const ok = await dialog({ title: 'Restaurar ' + s.domain, icon: 'refresh', html: '

Os arquivos do site e o banco vão voltar ao estado de ' + fdtY(m.created) + ' (' + E(dbLabel(m.db)) + ').

' + '
' + svg('check') + 'Antes, é feito um backup de segurança do estado atual (aparece como “antes de restaurar”).
' + '

vendor, node_modules, caches e logs atuais são mantidos; se faltar vendor, roda composer install no final.

' + '
Para confirmar, digite ' + E(s.name) + '
', buttons: [{ label: 'Cancelar', value: null }, { label: 'Restaurar', value: 'ok', kind: 'danger' }], onOpen(ov) { const i = qs('#rsC', ov), b = qs('.df button.danger', ov); const c = () => { b.disabled = i.value.trim() !== s.name; }; i.addEventListener('input', c); c(); }, async validate(ov) { const conf = qs('#rsC', ov).value.trim(); if (conf !== s.name) return false; const b = qs('.df button.danger', ov); b.disabled = true; b.textContent = 'Iniciando…'; try { return { job: (await api('restore', { name: s.name, file: m.file, confirm: conf })).job }; } catch (e) { qs('#rsErr', ov).innerHTML = errHtml(e); b.disabled = false; b.textContent = 'Restaurar'; return false; } } }); if (ok && ok.job) { await jobView(ok.job, 'Restaurar · ' + s.domain, 'refresh'); load(true); } return backups(s); } } /* ------------------------------------------------------------ deploy */ async function deploy(s) { if (!RW) return; let info, hist; try { [info, hist] = await Promise.all([api('deploy_info', { name: s.name }), jget('list&type=deploy&site=' + encodeURIComponent(s.name))]); } catch (e) { return fail(e); } if (info.running) return jobView(info.running, 'Deploy · ' + s.domain).then(() => load(true)); if (!info.laravel) { return dialog({ title: 'Deploy · ' + s.domain, icon: 'rocket', html: '

Não encontrei um projeto Laravel em ' + E(info.root) + ' (falta o arquivo artisan).

Coloque o projeto na pasta (ex.: git clone … ' + E(info.root) + ') e tente de novo.

' }); } const npmOk = info.pkg && info.node; const opt = (k, label, desc, on, extra) => ''; const histHtml = hist.jobs.length ? '
Últimos deploys
' + hist.jobs.map(J => '').join('') + '
' : ''; const r = await dialog({ title: 'Deploy · ' + s.domain, icon: 'rocket', wide: true, html: '

Pasta ' + E(info.root) + ' · PHP ' + E(info.php || '?') + '. Os comandos do artisan, composer e npm rodam como www-data.

' + '
' + opt('hot', 'Remover public/hot', 'arquivo do servidor de desenvolvimento do Vite' + (info.hot ? ' · existe agora' : ''), true) + opt('perms', 'Corrigir dono e permissões', 'www-data em tudo; escrita em storage, bootstrap/cache e database', true) + opt('composer', 'composer install', info.composer ? (info.vendor ? '' : 'a pasta vendor ainda não existe') : 'Composer não instalado (lk, opção 16)', info.composer, '') + opt('migrate', 'php artisan migrate --force', 'aplica as migrations no banco', true) + opt('optimize', 'php artisan optimize:clear', 'limpa caches de config, rotas e views', true) + opt('storage', 'php artisan storage:link', 'cria o link public/storage', true) + (npmOk ? opt('npm', 'npm ci && npm run build', 'compila os assets (Vite)', false) : info.pkg ? '
npm: o Node.js não está instalado neste servidor (lk, opção 8).
' : '') + '
' + histHtml + '
', buttons: [{ label: 'Cancelar', value: null }, { label: 'Iniciar deploy', value: 'ok', kind: 'pri' }], onOpen(ov, close) { qsa('[data-job]', ov).forEach(b => b.onclick = () => close({ view: b.dataset.job })); const c = qs('[data-o="composer"]', ov), nd = qs('[data-o="nodev"]', ov); const sync = () => { nd.disabled = !c.checked; if (!c.checked) nd.checked = false; }; c.onchange = sync; sync(); }, async validate(ov) { const opts = {}; qsa('[data-o]', ov).forEach(i => { opts[i.dataset.o] = i.checked; }); if (!['hot', 'perms', 'composer', 'migrate', 'optimize', 'storage', 'npm'].some(k => opts[k])) { qs('#dpErr', ov).textContent = 'Marque pelo menos um passo.'; return false; } const b = qs('.df button.pri', ov); b.disabled = true; b.textContent = 'Iniciando…'; try { return { job: (await api('deploy', { name: s.name, opts })).job }; } catch (e) { if (e.data && e.data.running) return { job: e.data.running }; qs('#dpErr', ov).innerHTML = errHtml(e); b.disabled = false; b.textContent = 'Iniciar deploy'; return false; } } }); if (r && r.view) return jobView(r.view, 'Deploy · ' + s.domain).then(() => deploy(s)); if (r && r.job) jobView(r.job, 'Deploy · ' + s.domain).then(() => load(true)); } /* ------------------------------------------------------------ inicialização */ window.SITES = { show() { if (!qs('#sites')) return; if (!inited) { inited = true; build(); load(); } else load(true); } }; if (location.hash === '#sites') window.SITES.show(); })(); LKM_EOF_SITESJS cat > "$DIR/db.js" <<'LKM_EOF_DBJS' /* LK Monitor - aba Bancos (MySQL/MariaDB e SQLite) */ (function () { 'use strict'; const U = window.UI; if (!U) return; const { E, qs, qsa, toast, fail, dialog, confirmDlg, menu, svg, copyText, fsize } = U; const C0 = typeof CFG !== 'undefined' ? CFG : {}; const TOKEN = C0.token || ''; const ROOT = C0.user === 'root'; const S = { tab: 'mysql', data: null, error: '', filter: '', busy: false }; let root, inited = false; async function api(act, body, raw) { let r; try { r = await fetch('?db=' + act, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-LKM-Token': TOKEN }, body: JSON.stringify(body || {}) }); } catch (e) { throw new Error('Sem conexão com o painel'); } if (r.status === 401) { location.reload(); throw new Error('Sessão expirada'); } if (raw && (r.headers.get('Content-Type') || '').startsWith('text/csv')) return r; let d; try { d = await r.json(); } catch (e) { throw new Error('Resposta inválida do servidor (' + r.status + ')'); } if (!d.ok) { const er = new Error(d.error || 'Erro desconhecido'); er.detail = d.detail || ''; er.data = d; throw er; } return d; } const errHtml = e => E(e.message) + (e.detail ? '
' + E(e.detail) + '
' : ''); const envLines = d => 'DB_CONNECTION=mysql\nDB_HOST=127.0.0.1\nDB_PORT=3306\nDB_DATABASE=' + d.name + '\nDB_USERNAME=' + d.user + '\nDB_PASSWORD=' + d.pass; const num = n => n == null ? '—' : Number(n).toLocaleString('pt-BR'); /* ------------------------------------------------------------ lista */ function build() { root = qs('#bancos'); if (!ROOT) { root.innerHTML = '

Bancos de dados

' + svg('lock') + '
Indisponível nesta instalação. Para ver e gerenciar os bancos, o painel precisa de acesso root (para entrar no MySQL pelo socket). Rode sudo lk › opção 33 › 1 e escolha root no acesso do terminal.
'; return; } root.innerHTML = '
' + '

Bancos de dados

' + '
' + '' + '' + '
' + '
' + '

Usuários do MySQL

'; root.addEventListener('click', onClick); qs('#dbQ').addEventListener('input', e => { S.filter = e.target.value.trim().toLowerCase(); render(); }); qsa('#dbTab button').forEach(b => b.onclick = () => { S.tab = b.dataset.t; qsa('#dbTab button').forEach(x => x.classList.toggle('on', x === b)); render(); }); } async function load(quiet) { if (S.busy || !ROOT) return; S.busy = true; if (!quiet && !S.data) qs('#dbList').innerHTML = '
Carregando bancos…
'; try { S.data = await api('overview'); S.error = ''; } catch (e) { S.error = e.message; } finally { S.busy = false; } render(); } function render() { if (!root || !ROOT) return; const list = qs('#dbList'), msg = qs('#dbMsg'), d = S.data; qs('#dbNew').hidden = S.tab !== 'mysql'; qs('#dbUsersCard').hidden = S.tab !== 'mysql' || !d || !d.mysql.ok; if (S.error) { list.innerHTML = '
' + E(S.error) + '
'; msg.innerHTML = ''; return; } if (!d) return; const f = S.filter; if (S.tab === 'mysql') { msg.innerHTML = d.mysql.ok ? '' : '
' + svg('warn') + '
' + E(d.mysql.error || 'MySQL indisponível') + '
'; qs('#dbNew').disabled = !d.mysql.ok; const v = d.dbs.filter(x => !f || (x.name + ' ' + x.users.join(' ') + ' ' + x.sites.join(' ')).toLowerCase().includes(f)); qs('#dbCount').textContent = d.mysql.ok ? d.dbs.length + (d.dbs.length === 1 ? ' banco' : ' bancos') + ' · ' + d.mysql.version : ''; list.innerHTML = !d.mysql.ok ? '' : !d.dbs.length ? '
' + svg('db') + 'Nenhum banco criado aindaCrie o primeiro com Novo banco.
' : !v.length ? '
Nada corresponde ao filtro.
' : v.map(rowMy).join(''); const us = d.users.filter(u => !f || (u.user + ' ' + u.dbs.join(' ')).toLowerCase().includes(f)); qs('#dbUCount').textContent = d.users.length ? d.users.length + (d.users.length === 1 ? ' usuário' : ' usuários') : ''; qs('#dbUsers').innerHTML = us.length ? us.map(rowUser).join('') : '
Nenhum usuário além dos do sistema.
'; } else { msg.innerHTML = ''; const v = d.sqlite.filter(x => !f || (x.path + ' ' + x.site).toLowerCase().includes(f)); qs('#dbCount').textContent = d.sqlite.length + (d.sqlite.length === 1 ? ' arquivo' : ' arquivos') + ' em /var/www'; list.innerHTML = !d.sqlite.length ? '
' + svg('db') + 'Nenhum arquivo SQLite encontradoProcuro arquivos .sqlite, .sqlite3 e .db dentro de /var/www (ex.: database/database.sqlite do Laravel).
' : !v.length ? '
Nada corresponde ao filtro.
' : v.map(rowLite).join(''); } } function rowMy(x) { return '
' + '
' + svg('db') + '
' + '' + (x.sites.length ? 'usado por ' + E(x.sites.join(', ')) : 'nenhum site usa este banco no .env') + '
' + '
' + num(x.tables) + (x.tables === 1 ? ' tabela' : ' tabelas') + '' + x.users.map(u => '' + svg('users') + E(u) + '').join('') + '
' + '
' + fsize(x.size) + '
' + '
' + '
'; } function rowUser(u) { return '
' + '
' + svg('users') + '
' + E(u.user) + '@' + E(u.host) + '' + '' + (u.sites.length ? 'no .env de ' + E(u.sites.join(', ')) : 'não aparece no .env de nenhum site') + '
' + '
' + (u.dbs.length ? u.dbs.map(b => '' + E(b) + '').join('') : 'sem banco') + '
' + '
' + (u.host === 'localhost' ? '' : '') + '
'; } function rowLite(x) { const i = x.path.lastIndexOf('/'); return '
' + '
' + svg('db') + '
' + '' + E(x.path.slice(0, i)) + '
' + '
' + E(x.site) + '' + (x.used ? 'usado pelo .env' : '') + '
' + '
' + fsize(x.size) + '
' + '
'; } function onClick(e) { const b = e.target.closest('[data-a]'); if (!b || b.disabled) return; const a = b.dataset.a, row = b.closest('.db-row'); if (a === 'reload') return load(); if (a === 'new') return createDb(); if (!row) return; const d = S.data; if (row.dataset.db) { const x = d.dbs.find(y => y.name === row.dataset.db); if (a === 'open') return viewer({ type: 'mysql', db: x.name }, x.name); if (a === 'more') { const r = b.getBoundingClientRect(); return menu(Math.max(6, r.right - 240), r.bottom + 4, [ { l: 'Abrir tabelas e consultas', i: 'table', fn: () => viewer({ type: 'mysql', db: x.name }, x.name) }, ...x.users.map(u => ({ l: 'Trocar senha de ' + u, i: 'key', fn: () => changePass(d.users.find(z => z.user === u && z.host === 'localhost') || { user: u, sites: [] }) })), { l: 'Copiar nome', i: 'copy', fn: () => copyText(x.name).then(() => toast('Copiado')) }, '-', { l: 'Apagar banco…', i: 'trash', danger: true, fn: () => dropDb(x) } ]); } } else if (row.dataset.user) { if (a === 'pass') return changePass(d.users.find(u => u.user === row.dataset.user && u.host === row.dataset.host)); } else if (row.dataset.path) { if (a === 'open') return viewer({ type: 'sqlite', path: row.dataset.path }, row.dataset.path.split('/').pop()); } } /* ------------------------------------------------------------ criar / apagar / senha */ function credsHtml(d, title) { return '
' + svg('db') + ' ' + title + '
' + '
' + svg('warn') + 'Anote agora: a senha não será mostrada de novo.
' + '
Banco
' + E(d.name) + '
Usuário
' + E(d.user) + '
Senha
' + E(d.pass) + '
Servidor
127.0.0.1:3306
' + '
Linhas para o .env
' + E(envLines(d)) + '
'; } const bindCopy = ov => qsa('[data-copy]', ov).forEach(b => b.onclick = () => copyText(qs('#' + b.dataset.copy, ov).textContent).then(() => toast('Copiado'))); async function createDb() { const r = await dialog({ title: 'Novo banco MySQL', icon: 'db', cls: 'nfd', html: '
Nome do banco
' + '
Letras, números e _ (ex.: loja ou loja_producao).
' + '
Usuário
' + '
Recebe acesso total só a este banco. A senha é gerada automaticamente.
', buttons: [{ label: 'Cancelar', value: null }, { label: 'Criar banco', value: 'ok', kind: 'pri' }], onOpen(ov) { const n = qs('#cdN', ov), u = qs('#cdU', ov); n.addEventListener('input', () => { const v = n.value.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_'); if (v !== n.value) n.value = v; u.placeholder = v ? v.slice(0, 32) : 'igual ao nome do banco'; }); }, async validate(ov) { const name = qs('#cdN', ov).value.trim(), user = qs('#cdU', ov).value.trim(), err = qs('#cdErr', ov); if (!/^[A-Za-z0-9_]{1,64}$/.test(name)) { err.textContent = 'Nome inválido.'; return false; } if (user && !/^[A-Za-z0-9_]{1,32}$/.test(user)) { err.textContent = 'Usuário inválido.'; return false; } const b = qs('.df button.pri', ov); b.disabled = true; b.textContent = 'Criando…'; try { return { d: await api('create', { name, user }) }; } catch (e) { err.innerHTML = errHtml(e); b.disabled = false; b.textContent = 'Criar banco'; return false; } } }); if (!r) return; load(true); dialog({ title: 'Banco criado', icon: 'check', wide: true, html: '
' + credsHtml(r.d.db, 'Credenciais') + '
', buttons: [{ label: 'Concluir', value: true, kind: 'pri' }], onOpen: bindCopy }); } async function dropDb(x) { const r = await dialog({ title: 'Apagar o banco ' + x.name, icon: 'trash', html: '

Todas as tabelas e dados de ' + E(x.name) + ' serão apagados. Antes disso é feito um backup automático (.sql.gz) em /var/backups/lkmonitor/_bancos.

' + (x.sites.length ? '
' + svg('warn') + 'Usado pelo .env de ' + E(x.sites.join(', ')) + '.
' : '') + x.only.map(u => '').join('') + '
Para confirmar, digite ' + E(x.name) + '
', buttons: [{ label: 'Cancelar', value: null }, { label: 'Apagar', value: 'ok', kind: 'danger' }], onOpen(ov) { const i = qs('#ddC', ov), b = qs('.df button.danger', ov); const c = () => { b.disabled = i.value.trim() !== x.name; }; i.addEventListener('input', c); c(); }, async validate(ov) { const conf = qs('#ddC', ov).value.trim(); if (conf !== x.name) return false; const users = qsa('[data-u]', ov).filter(i => i.checked).map(i => i.dataset.u); const b = qs('.df button.danger', ov); b.disabled = true; b.textContent = 'Fazendo backup…'; try { return { d: await api('drop', { name: x.name, confirm: conf, users }) }; } catch (e) { qs('#ddErr', ov).innerHTML = errHtml(e); b.disabled = false; b.textContent = 'Apagar'; return false; } } }); if (!r) return; toast('Banco ' + x.name + ' apagado · backup em ' + r.d.backup); load(true); } async function changePass(u) { if (!u) return; const r = await dialog({ title: 'Trocar senha de ' + u.user, icon: 'key', cls: 'nfd', html: '
' + '' + '' + (u.sites && u.sites.length ? '
Sites que usam este usuário
' + u.sites.map(s => '').join('') + '
Depois de atualizar, roda php artisan config:clear no site.
' : '
Nenhum site usa este usuário no .env; lembre de atualizar onde ele for usado.
') + '
', buttons: [{ label: 'Cancelar', value: null }, { label: 'Trocar senha', value: 'ok', kind: 'pri' }], onOpen(ov) { qsa('input[name=pm]', ov).forEach(i => i.onchange = () => { const t = qs('input[name=pm]:checked', ov).value === 'typed'; qs('#cpP', ov).hidden = !t; if (t) qs('#cpP', ov).focus(); }); }, async validate(ov) { const typed = qs('input[name=pm]:checked', ov).value === 'typed', pass = typed ? qs('#cpP', ov).value : ''; if (typed && (pass.length < 8 || /[\s'"\\`$]/.test(pass))) { qs('#cpErr', ov).textContent = 'Use pelo menos 8 caracteres, sem espaços, aspas, barra invertida, crase ou $.'; return false; } const sites = qsa('[data-s]', ov).filter(i => i.checked).map(i => i.dataset.s); const b = qs('.df button.pri', ov); b.disabled = true; b.textContent = 'Trocando…'; try { return { d: await api('password', { user: u.user, pass, sites }) }; } catch (e) { qs('#cpErr', ov).innerHTML = errHtml(e); b.disabled = false; b.textContent = 'Trocar senha'; return false; } } }); if (!r) return; dialog({ title: 'Senha trocada', icon: 'check', html: '
' + svg('warn') + 'Anote agora: a senha não será mostrada de novo.
' + '
' + E(r.d.pass) + '
' + (r.d.updated.length ? '

.env atualizado em: ' + E(r.d.updated.join(', ')) + '

' : '') + '
', buttons: [{ label: 'Concluir', value: true, kind: 'pri' }], onOpen: bindCopy }); } /* ------------------------------------------------------------ visualizador */ function viewer(src, label) { const kind = src.type; const V = { tables: [], table: null, tab: 'data', page: 1, q: '', sort: '', desc: false, write: false, lastSql: '' }; const el = document.createElement('div'); el.className = 'dbv'; el.innerHTML = '
' + svg('db') + '
' + E(label) + '' + (kind === 'mysql' ? 'MySQL / MariaDB' : E(src.path)) + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
'; document.body.appendChild(el); document.body.style.overflow = 'hidden'; const $ = s => qs(s, el); const close = () => { el.remove(); document.body.style.overflow = ''; document.removeEventListener('keydown', key, true); }; const key = e => { if (e.key === 'Escape' && !qs('.fm-ov') && !qs('#fmMenu')) { e.preventDefault(); close(); } }; document.addEventListener('keydown', key, true); const grid = (cols, rows, sortable) => { if (!cols.length) return '
Nenhuma coluna.
'; return '' + cols.map(c => '' + E(c) + (sortable && V.sort === c ? (V.desc ? ' ↓' : ' ↑') : '') + '').join('') + '' + (rows.length ? rows.map(r => '' + r.map(v => v === null ? '' : '').join('') + '').join('') : '') + '
NULL' + E(v) + '
Nenhuma linha.
'; }; async function loadTables() { try { V.tables = (await api('tables', { src })).tables; renderTables(); $('#vSel').innerHTML = V.tables.map(t => '').join(''); if (V.tables.length) pick(V.tables[0].name); else { $('#vGrid').innerHTML = '
' + svg('table') + 'Banco sem tabelasUse a aba Consulta SQL ou rode as migrations do site.
'; setTab('sql'); } } catch (e) { $('#vTL').innerHTML = '
' + E(e.message) + '
'; } } function renderTables() { const f = $('#vTQ').value.trim().toLowerCase(); const v = V.tables.filter(t => !f || t.name.toLowerCase().includes(f)); $('#vTL').innerHTML = v.length ? v.map(t => '').join('') : '
Nenhuma tabela.
'; } function pick(name) { V.table = name; V.page = 1; V.q = ''; V.sort = ''; V.desc = false; $('#vQ').value = ''; $('#vSel').value = name; renderTables(); if (!$('#vSql').value.trim()) $('#vSql').value = 'SELECT * FROM ' + (kind === 'mysql' ? '`' + name + '`' : '"' + name + '"') + ' LIMIT 50'; if (V.tab === 'sql') setTab('data'); else refresh(); } function setTab(t) { V.tab = t; qsa('#vTabs button', el).forEach(b => b.classList.toggle('on', b.dataset.t === t)); $('#pData').hidden = t !== 'data'; $('#pStruct').hidden = t !== 'struct'; $('#pSql').hidden = t !== 'sql'; if (t === 'sql') { $('#vInfo').textContent = ''; setTimeout(() => $('#vSql').focus(), 30); } else refresh(); } async function refresh() { if (!V.table) return; const t = V.table; if (V.tab === 'data') { $('#vGrid').classList.add('busy'); try { const d = await api('rows', { src, table: t, page: V.page, q: V.q, sort: V.sort, desc: V.desc }); if (t !== V.table) return; $('#vGrid').innerHTML = grid(d.columns, d.rows, true); V.page = d.page; V.pages = d.pages; $('#vPg').textContent = d.page + ' / ' + d.pages; $('#vInfo').textContent = num(d.total) + (d.total === 1 ? ' linha' : ' linhas') + (V.q ? ' com “' + V.q + '”' : '') + ' · 50 por página'; qs('[data-v=prev]', el).disabled = d.page <= 1; qs('[data-v=next]', el).disabled = d.page >= d.pages; } catch (e) { $('#vGrid').innerHTML = '
' + E(e.message) + '
'; } $('#vGrid').classList.remove('busy'); } else if (V.tab === 'struct') { try { const d = await api('columns', { src, table: t }); $('#vCols').innerHTML = grid(['Coluna', 'Tipo', 'Nulo', 'Chave', 'Padrão', 'Extra'], d.columns.map(c => [c.name, c.type, c.null ? 'sim' : 'não', c.key || '', c.default, c.extra || '']), false); $('#vInfo').textContent = d.columns.length + ' colunas'; } catch (e) { $('#vCols').innerHTML = '
' + E(e.message) + '
'; } } } async function runSql() { const sql = $('#vSql').value.trim(); if (!sql) return; const isWrite = /^\s*(insert|update|delete|replace)\b/i.test(sql.replace(/^(\s*(--[^\n]*\n|\/\*[\s\S]*?\*\/))*/, '')); if (isWrite && V.write && !(await confirmDlg('Executar alteração?', 'Este comando vai alterar dados em ' + E(label) + ' e não pode ser desfeito pelo painel:
' + E(sql.slice(0, 600)) + '
', 'Executar', true))) return; const b = qs('[data-v=run]', el); b.disabled = true; $('#vRes').textContent = 'Executando…'; $('#vRes').className = 'dbv-res st-dim'; try { const d = await api('query', { src, sql, write: V.write }); if (d.affected != null && !d.columns.length) { $('#vRes').innerHTML = svg('check') + ' ' + num(d.affected) + (d.affected === 1 ? ' linha alterada' : ' linhas alteradas') + ' em ' + d.ms + ' ms'; $('#vRes').className = 'dbv-res ok'; $('#vOut').innerHTML = ''; qs('[data-v=expsql]', el).disabled = true; const tb = await api('tables', { src }).catch(() => null); if (tb) { V.tables = tb.tables; renderTables(); } } else { $('#vRes').textContent = num(d.rows.length) + (d.rows.length === 1 ? ' linha' : ' linhas') + (d.more ? ' (mostrando as primeiras; exporte para ver mais)' : '') + ' em ' + d.ms + ' ms'; $('#vRes').className = 'dbv-res'; $('#vOut').innerHTML = grid(d.columns, d.rows, false); V.lastSql = sql; qs('[data-v=expsql]', el).disabled = false; } } catch (e) { $('#vRes').innerHTML = '' + E(e.message) + '' + (e.data && e.data.needs_write ? ' ' : ''); $('#vRes').className = 'dbv-res err'; } b.disabled = false; } async function exportCsv(body, name) { try { toast('Preparando o CSV…'); const r = await api('export', Object.assign({ src }, body), true); if (!(r instanceof Response)) throw new Error('Falha ao exportar'); const blob = await r.blob(), a = document.createElement('a'); const cd = r.headers.get('Content-Disposition') || '', m = /filename="([^"]+)"/.exec(cd); a.href = URL.createObjectURL(blob); a.download = m ? m[1] : (name || 'resultado') + '.csv'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); if (r.headers.get('X-LKM-More') === '1') toast('CSV com as primeiras 20.000 linhas', 'er'); } catch (e) { fail(e); } } function setWrite(on) { V.write = on; const w = $('#vW'); w.classList.toggle('on', on); w.classList.toggle('danger', on); $('#pSql').classList.toggle('writing', on); if (on) { $('#vRes').innerHTML = svg('warn') + ' Alterações liberadas: INSERT, UPDATE e DELETE pedem confirmação antes de rodar.'; $('#vRes').className = 'dbv-res warn'; } else { $('#vRes').textContent = 'Somente leitura.'; $('#vRes').className = 'dbv-res st-dim'; } } el.addEventListener('click', async e => { const t = e.target.closest('[data-tb]'); if (t) return pick(t.dataset.tb); const tab = e.target.closest('#vTabs button'); if (tab) return setTab(tab.dataset.t); const th = e.target.closest('th[data-sort]'); if (th) { const c = th.dataset.sort; if (V.sort === c) V.desc = !V.desc; else { V.sort = c; V.desc = false; } V.page = 1; return refresh(); } const b = e.target.closest('[data-v]'); if (!b || b.disabled) return; const a = b.dataset.v; if (a === 'close') close(); else if (a === 'prev' && V.page > 1) { V.page--; refresh(); } else if (a === 'next' && V.page < (V.pages || 1)) { V.page++; refresh(); } else if (a === 'run') runSql(); else if (a === 'write') { if (V.write) return setWrite(false); if (await confirmDlg('Permitir alterações?', 'Libera INSERT, UPDATE e DELETE na consulta SQL deste banco. Cada comando ainda pede confirmação.', 'Permitir', true)) setWrite(true); } else if (a === 'exptable' && V.table) exportCsv({ table: V.table }, V.table); else if (a === 'expsql' && V.lastSql) exportCsv({ sql: V.lastSql }, 'consulta'); }); $('#vTQ').addEventListener('input', renderTables); $('#vSel').addEventListener('change', e => pick(e.target.value)); $('#vQ').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); V.q = e.target.value.trim(); V.page = 1; refresh(); } }); $('#vSql').addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); runSql(); } else if (e.key === 'Tab') { e.preventDefault(); const t = e.target, p = t.selectionStart; t.value = t.value.slice(0, p) + ' ' + t.value.slice(t.selectionEnd); t.selectionStart = t.selectionEnd = p + 2; } }); loadTables(); } /* ------------------------------------------------------------ inicialização */ window.DB = { show() { if (!qs('#bancos')) return; if (!inited) { inited = true; build(); load(); } else load(true); } }; if (location.hash === '#bancos') window.DB.show(); })(); LKM_EOF_DBJS cat > "$DIR/config.js" <<'LKM_EOF_CONFJS' /* LK Monitor - aba Config: alertas, canais (Telegram / WhatsApp), histórico de alertas e backup automático */ (function () { 'use strict'; const U = window.UI; if (!U) return; const { E, qs, qsa, toast, fail, confirmDlg, svg } = U; const C0 = typeof CFG !== 'undefined' ? CFG : {}; const TOKEN = C0.token || ''; const ROOT = C0.user === 'root'; let root, inited = false, D = null, BK = null, waMode = 'webhook', waSeq = 0; async function req(url, body) { let r; try { r = await fetch(url, body === undefined ? { credentials: 'same-origin', cache: 'no-store' } : { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-LKM-Token': TOKEN }, body: JSON.stringify(body) }); } catch (e) { throw new Error('Sem conexão com o painel'); } if (r.status === 401) { location.reload(); throw new Error('Sessão expirada'); } let d; try { d = await r.json(); } catch (e) { throw new Error('Resposta inválida do servidor (' + r.status + ')'); } if (!d.ok) { const er = new Error(d.error || 'Erro desconhecido'); er.data = d; throw er; } return d; } const p2 = n => String(n).padStart(2, '0'); const fdt = t => { const d = new Date(t * 1000); return p2(d.getDate()) + '/' + p2(d.getMonth() + 1) + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes()); }; const sw = (id, on) => ''; const num = (id, v, min, max, w) => ''; function build() { root = qs('#config'); root.innerHTML = '
' + '

' + svg('bell') + 'Alertas

' + '

' + svg('send') + 'Canais de aviso

' + '
' + '
' + '
' + '

' + svg('archive') + 'Backup automático

' + '

' + svg('log') + 'Histórico de alertas

' + '
'; qs('#cfSave').onclick = save; qs('#cfClr').onclick = clearHist; root.addEventListener('click', e => { const b = e.target.closest('[data-sw]'); if (b && !b.disabled) { b.classList.toggle('on'); dirty(); } }); root.addEventListener('input', e => { if (e.target.closest('#cfRules,#cfCh')) dirty(); }); } const dirty = () => { qs('#cfMsg').textContent = 'alterações não salvas'; qs('#cfMsg').className = 'cf-dirty'; }; async function load() { try { D = await req('?cfg=get'); } catch (e) { qs('#cfRules').innerHTML = '
' + E(e.message) + '
'; return; } renderRules(); renderCh(); renderHist(); qs('#cfMsg').textContent = ''; qs('#cfMsg').className = 'st-dim'; loadBk(); } function renderRules() { const r = D.settings.rules, act = D.active || []; qs('#cfAct').innerHTML = act.length ? '' + act.length + ' alerta' + (act.length > 1 ? 's' : '') + ' ativo' + (act.length > 1 ? 's' : '') + '' : 'tudo normal'; const row = (k, html, hint) => '
' + sw('r_' + k, r[k].on) + '
' + html + '
' + (hint ? '' + hint + '' : '') + '
'; qs('#cfRules').innerHTML = row('temp', 'Temperatura da CPU acima de ' + num('temp_l', r.temp.limit, 30, 150) + ' °C por ' + num('temp_m', r.temp.minutes, 1, 120) + ' min') + row('disk', 'Disco acima de ' + num('disk_l', r.disk.limit, 1, 100) + ' % de uso', 'vale para cada disco montado') + row('ram', 'Memória RAM acima de ' + num('ram_l', r.ram.limit, 1, 100) + ' % por ' + num('ram_m', r.ram.minutes, 1, 120) + ' min') + row('service', 'Serviço parado', D.services.length ? 'verifica: ' + E(D.services.join(', ')) : 'nenhum dos serviços conhecidos está instalado') + row('site', 'Site fora do ar', D.sites.length ? 'testa ' + D.sites.length + ' site' + (D.sites.length > 1 ? 's' : '') + ' ligado' + (D.sites.length > 1 ? 's' : '') + ' em 127.0.0.1 (sem resposta ou erro 5xx, 2 vezes seguidas)' : 'nenhum site ligado no momento') + row('ssh', 'Mais de ' + num('ssh_l', r.ssh.limit, 1, 100000, 76) + ' falhas de login no SSH em 10 min') + '
Repetir o mesmo alerta a cada ' + num('repeat', D.settings.repeat, 5, 1440, 70) + ' min enquanto continuar. Quando volta ao normal, também avisa.
'; } function renderCh() { const t = D.settings.telegram, w = D.settings.whatsapp; qs('#cfCh').innerHTML = '
' + sw('t_on', t.on) + 'Telegram
' + '
' + '
' + 'Crie o bot com o @BotFather, mande uma mensagem para ele e pegue o seu chat id com o @userinfobot.' + (t.token_set ? ' O token salvo aparece mascarado; deixe como está para manter.' : '') + '
' + '
' + sw('w_on', w.on) + '
' + (D.gowa ? '
' + '
' + 'Só dígitos, com código do país e DDD (55 para o Brasil). Para avisar mais de uma pessoa, separe por vírgula. As mensagens saem pelo número conectado neste servidor.' + '
' : '') + '
' + '
' + '
' + '
' + '
' + 'Funciona com Wablas, GOWA ou qualquer gateway que receba uma requisição HTTP. Valores dos cabeçalhos salvos aparecem mascarados; deixe a linha como está para manter.' + (D.gowa ? '' : '') + '
'; qsa('[data-test]', qs('#cfCh')).forEach(b => b.onclick = () => test(b)); waMode = D.gowa && w.mode_eff === 'gowa' ? 'gowa' : 'webhook'; if (D.gowa) { qs('#waHook').onclick = () => { setWa('webhook'); dirty(); }; qs('#waLocal').onclick = () => { setWa('gowa'); dirty(); }; } setWa(waMode); } /** Alterna o canal WhatsApp entre o serviço deste servidor e um gateway próprio (webhook). */ function setWa(mode) { waMode = D.gowa ? mode : 'webhook'; qs('#wTitle').textContent = waMode === 'gowa' ? 'WhatsApp deste servidor' : 'WhatsApp (gateway próprio)'; if (qs('#wGowa')) qs('#wGowa').hidden = waMode !== 'gowa'; qs('#wHook').hidden = waMode === 'gowa'; if (waMode === 'gowa') waStatus(); } async function waStatus() { const box = qs('#waSt'); if (!box) return; const seq = ++waSeq; box.className = 'wa-st'; box.innerHTML = 'Verificando o número…'; let d; let g = D.gowa || {}; try { const r = await req('?cfg=wa_status'); d = r.st; g = r.gowa || g; } catch (e) { d = { ok: false, logged: null, error: e.message }; } if (seq !== waSeq || !qs('#waSt')) return; const host = location.hostname.includes(':') ? '[' + location.hostname + ']' : location.hostname; const page = g.page_port ? 'Abrir página do WhatsApp' : ''; const st = d.logged === true ? ['ok', 'Conectado'] : d.ok ? ['er', 'Número desconectado'] : ['er', 'Serviço do WhatsApp parado']; box.className = 'wa-st ' + st[0]; box.innerHTML = '' + st[1] + '' + page + '' + (d.logged !== true ? '' + E(d.error || 'Abra a página do WhatsApp e leia o QR code com o celular.') + '' : ''); qs('#waRe').onclick = waStatus; } function collect() { const on = id => qs('#' + id).classList.contains('on'), v = id => qs('#' + id).value; return { repeat: +v('repeat'), rules: { temp: { on: on('r_temp'), limit: +v('temp_l'), minutes: +v('temp_m') }, disk: { on: on('r_disk'), limit: +v('disk_l') }, ram: { on: on('r_ram'), limit: +v('ram_l'), minutes: +v('ram_m') }, service: { on: on('r_service') }, site: { on: on('r_site') }, ssh: { on: on('r_ssh'), limit: +v('ssh_l') } }, telegram: { on: on('t_on'), token: v('t_token'), chat: v('t_chat') }, whatsapp: Object.assign({ on: on('w_on'), mode: waMode, method: v('w_method'), url: v('w_url'), headers: v('w_headers'), body: v('w_body'), phone: v('w_phone') }, qs('#w_numbers') ? { numbers: v('w_numbers') } : {}) }; } async function save() { const b = qs('#cfSave'); b.disabled = true; try { const d = await req('?cfg=save', { settings: collect() }); D.settings = d.settings; renderRules(); renderCh(); qs('#cfMsg').textContent = 'salvo'; qs('#cfMsg').className = 'cf-ok'; toast('Alertas e canais salvos'); } catch (e) { fail(e); } b.disabled = false; } async function test(b) { const ch = b.dataset.test, lbl = b.innerHTML; b.disabled = true; b.textContent = 'Enviando…'; try { await req('?cfg=test', { channel: ch, settings: collect() }); toast('Mensagem de teste enviada pelo ' + (ch === 'telegram' ? 'Telegram' : 'WhatsApp')); } catch (e) { fail(e); } if (ch === 'whatsapp' && waMode === 'gowa') waStatus(); b.disabled = false; b.innerHTML = lbl; } function renderHist() { const h = D.history || []; const ico = { alert: 'warn', repeat: 'warn', ok: 'check' }; qs('#cfHist').innerHTML = h.length ? h.map(x => '
' + svg(ico[x.kind] || 'info') + '
' + E(x.msg) + '
' + fdt(x.t) + ' · ' + (x.sent && Object.keys(x.sent).length ? Object.entries(x.sent).map(([c, r]) => '' + (c === 'telegram' ? 'Telegram' : 'WhatsApp') + (r === true ? ' ✓' : ' ✗') + '').join(' ') : 'nenhum canal ativo') + '
').join('') : '
' + svg('bell') + 'Nenhum alerta até agoraAs regras são verificadas a cada minuto pelo coletor.
'; } async function clearHist() { if (!(await confirmDlg('Limpar histórico?', 'Apaga a lista dos últimos alertas (as regras continuam valendo).', 'Limpar', true))) return; try { await req('?cfg=clear', {}); D.history = []; renderHist(); } catch (e) { fail(e); } } /* ------------------------------------------------------------ backup automático */ async function loadBk() { const box = qs('#cfBk'); if (!ROOT) { box.innerHTML = '
' + svg('lock') + '
Precisa de acesso root no painel (sudo lk › 33 › 1 › escolha root).
'; return; } try { BK = await req('?sites=backup_cfg', {}); } catch (e) { box.innerHTML = '
' + E(e.message) + '
'; return; } const c = BK.cfg; qs('#bkNext').textContent = BK.next ? 'próxima: ' + fdt(BK.next) : ''; box.innerHTML = '
' + sw('b_on', c.enabled) + '
Todo dia às e manter os últimos ' + num('b_keep', c.keep, 1, 365, 64) + ' backups automáticos de cada site
' + 'Backups manuais e os feitos antes de restaurar não são apagados. Arquivos em /var/backups/lkmonitor/<site>/
' + '
Sites incluídos
' + (BK.sites.length ? '
' + BK.sites.map(s => '').join('') + '
' : '
Nenhum site criado ainda.
') + '
'; qs('#bkSave').onclick = async () => { const cfg = { enabled: qs('#b_on').classList.contains('on'), time: qs('#b_time').value, keep: +qs('#b_keep').value, sites: qsa('[data-bs]', box).filter(i => i.checked).map(i => i.dataset.bs) }; const b = qs('#bkSave'); b.disabled = true; try { await req('?sites=backup_cfg_set', { cfg }); toast('Backup automático salvo'); await loadBk(); } catch (e) { fail(e); b.disabled = false; } }; } window.CONF = { show() { if (!qs('#config')) return; if (!inited) { inited = true; build(); } load(); } }; if (location.hash === '#config') window.CONF.show(); })(); LKM_EOF_CONFJS cat > "$DIR/term.js" <<'LKM_EOF_TERMJS' /* LK Monitor - terminal completo (xterm.js + serviço lkmonitor-term) * Cada aba é um shell de verdade no servidor (nano, htop, o menu do lk, Ctrl+C...). * As sessões continuam vivas por um tempo quando a página fecha e voltam como estavam. */ (function () { 'use strict'; const U = window.UI; if (!U) return; const C0 = typeof CFG !== 'undefined' ? CFG : {}; const { E, qs, qsa, toast, svg } = U; const $ = id => document.getElementById(id); const coarse = U.coarse; const ls = { get(k, d) { try { const v = localStorage.getItem('lkm.term.' + k); return v == null ? d : JSON.parse(v); } catch (e) { return d; } }, set(k, v) { try { localStorage.setItem('lkm.term.' + k, JSON.stringify(v)); } catch (e) { } } }; // ícones que o gerenciador de arquivos ainda não tem Object.assign(U.IC, { expand: '', shrink: '', minus: '', zap: '' }); /* ------------------------------------------------------------ xterm.js: cópia local (baixada pelo instalador) ou CDN */ const XV = '5.5.0', FV = '0.10.0'; const SOURCES = [ { css: 'vendor/xterm/xterm.css?v=' + XV, js: 'vendor/xterm/xterm.js?v=' + XV, fit: 'vendor/xterm/addon-fit.js?v=' + FV }, { css: 'https://cdn.jsdelivr.net/npm/@xterm/xterm@' + XV + '/css/xterm.css', js: 'https://cdn.jsdelivr.net/npm/@xterm/xterm@' + XV + '/lib/xterm.js', fit: 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@' + FV + '/lib/addon-fit.js' }, { css: 'https://unpkg.com/@xterm/xterm@' + XV + '/css/xterm.css', js: 'https://unpkg.com/@xterm/xterm@' + XV + '/lib/xterm.js', fit: 'https://unpkg.com/@xterm/addon-fit@' + FV + '/lib/addon-fit.js' } ]; function tag(url) { return new Promise((res, rej) => { let el; if (/\.css(\?|$)/.test(url)) { el = document.createElement('link'); el.rel = 'stylesheet'; el.href = url; } else { el = document.createElement('script'); el.src = url; el.async = false; } const t = setTimeout(() => { el.remove(); rej(new Error('demorou')); }, 10000); el.onload = () => { clearTimeout(t); res(el); }; el.onerror = () => { clearTimeout(t); el.remove(); rej(new Error('falhou')); }; document.head.appendChild(el); }); } let libP = null; function loadLib() { if (window.Terminal) return Promise.resolve(); if (libP) return libP; libP = (async () => { for (const s of SOURCES) { try { const css = await tag(s.css); try { await tag(s.js); await tag(s.fit).catch(() => null); } catch (e) { css.remove(); throw e; } if (window.Terminal) return; } catch (e) { /* tenta a próxima fonte */ } } libP = null; throw new Error('Não foi possível carregar o terminal (xterm.js). Atualize o LK Monitor com o servidor ligado à internet, ou use o modo simples.'); })(); return libP; } /* ------------------------------------------------------------ API do painel */ async function api(act, body) { let r; try { r = await fetch('?term=' + act, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-LKM-Token': C0.token || '' }, body: JSON.stringify(body || {}) }); } catch (e) { const er = new Error('Sem conexão com o painel'); er.net = true; throw er; } if (r.status === 401) { location.reload(); throw new Error('Sessão expirada'); } let d; try { d = await r.json(); } catch (e) { throw new Error('Resposta inválida do painel (' + r.status + ')'); } if (!d.ok) { const er = new Error(d.error || 'Erro'); er.down = !!d.down; er.off = !!d.off; throw er; } return d; } /* ------------------------------------------------------------ estado */ const THEME = { background: '#05070c', foreground: '#cfd7e6', cursor: '#9cc2ff', cursorAccent: '#05070c', selectionBackground: '#5b9cff55', black: '#1e2940', red: '#ff7b72', green: '#7ee787', yellow: '#f5c542', blue: '#5b9cff', magenta: '#c08bff', cyan: '#22d3ee', white: '#cfd7e6', brightBlack: '#56627d', brightRed: '#ffa198', brightGreen: '#a5f0b0', brightYellow: '#ffd866', brightBlue: '#9cc2ff', brightMagenta: '#d7b8ff', brightCyan: '#67e8f9', brightWhite: '#ffffff' }; const FONT = '"JetBrains Mono", "Cascadia Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, "DejaVu Sans Mono", "Liberation Mono", monospace'; const QUICK = ['lk', 'htop', 'lk status', 'lk sites', 'df -h', 'free -h', 'ip -br a', 'systemctl --failed', 'journalctl -f -n 50', 'tail -f /var/log/apache2/error.log']; const S = { tabs: [], cur: null, built: false, mode: ls.get('mode', 'full'), fs: ls.get('fs', coarse ? 12 : 13), full: false, ctrl: false, alt: false, n: 0, error: '' }; let root, host, visible = false; const canFull = () => !!(C0.user && C0.term); const active = () => visible && S.mode === 'full' && canFull() && !S.error; /* ------------------------------------------------------------ montagem */ function build() { root = $('xt'); root.innerHTML = '
' + '
' + '' + '' + '' + '' + '' + '' + '' + '
' + '
' + '
' + KEYS.map(k => '').join('') + '
' + '
' + E(C0.user === 'root' ? 'root · acesso total' : C0.user || '') + '' + 'Ctrl+C com texto selecionado copia · Ctrl+Shift+V cola · o terminal continua aberto se você sair da página
' + '
'; host = $('xtHost'); root.addEventListener('click', onBar); const keys = $('xtKeys'); keys.addEventListener('pointerdown', e => { if (e.target.closest('button')) e.preventDefault(); }); // não fecha o teclado do celular keys.addEventListener('click', e => { const b = e.target.closest('button[data-k]'); if (b) key(b.dataset.k); }); window.addEventListener('resize', fitSoon); if (window.visualViewport) window.visualViewport.addEventListener('resize', fitSoon); document.addEventListener('visibilitychange', () => { if (!document.hidden) S.tabs.forEach(t => { if (t.state === 'off') connect(t); }); }); window.addEventListener('online', () => S.tabs.forEach(t => { if (t.state === 'off') connect(t); })); S.built = true; } // teclas extras para o celular: [valor, rótulo, classe] const KEYS = [['esc', 'Esc'], ['tab', 'Tab'], ['ctrl', 'Ctrl', 'mod'], ['alt', 'Alt', 'mod'], ['up', '↑'], ['down', '↓'], ['left', '←'], ['right', '→'], ['|', '|'], ['/', '/'], ['-', '-'], ['~', '~'], ['home', 'Home'], ['end', 'End'], ['pgup', 'PgUp'], ['pgdn', 'PgDn'], ['^c', 'Ctrl+C'], ['^d', 'Ctrl+D'], ['^z', 'Ctrl+Z']]; /* ------------------------------------------------------------ tamanho */ let fitT = 0; function fitSoon() { clearTimeout(fitT); fitT = setTimeout(fit, 60); } function layout() { const box = $('xtBox'); if (!box || !visible) return; const vv = window.visualViewport; if (S.full) { // tela cheia: acompanha o teclado do celular box.style.top = (vv ? vv.offsetTop : 0) + 'px'; box.style.height = (vv ? vv.height : window.innerHeight) + 'px'; return; } box.style.top = ''; // altura que cabe na tela com a página no topo (não depende da rolagem, senão a caixa "anda" enquanto rola) const avail = vv ? vv.height : window.innerHeight; const top = box.getBoundingClientRect().top + window.scrollY; box.style.height = Math.max(260, Math.floor(avail - top - (innerWidth <= 600 ? 8 : 16))) + 'px'; } function fit() { if (!visible || !S.cur) return; layout(); const t = S.cur; if (t.fit) { try { t.fit.fit(); } catch (e) { } } } /* ------------------------------------------------------------ abas */ function paintTabs() { const el = $('xtTabs'); if (!el) return; el.innerHTML = S.tabs.map((t, i) => { const st = t.state === 'on' ? 'on' : t.state === 'connecting' ? 'wait' : t.state === 'ended' ? 'end' : 'off'; return '
' + E(short(t.title)) + '' + '
'; }).join(''); const st = $('xtSt'); if (st && S.cur) st.textContent = { on: 'conectado', connecting: 'conectando…', off: 'sem conexão · tentando de novo', ended: 'sessão encerrada', kicked: 'aberto em outra janela' }[S.cur.state] || ''; } function short(title) { const m = /^[^@\s]+@[^:\s]+:\s*(.*)$/.exec(title || ''); let s = m ? m[1] || '~' : title || 'Terminal'; if (s.length > 28) s = '…' + s.slice(-27); return s; } function saveTabs() { ls.set('sids', S.tabs.filter(t => t.sid && t.state !== 'ended').map(t => t.sid)); } function newTab(sid, select) { const t = { sid: sid || '', title: 'Terminal ' + (++S.n), state: 'connecting', ws: null, retry: 0, timer: 0, queue: [], el: document.createElement('div') }; t.el.className = 'xt-term'; host.appendChild(t.el); t.term = new window.Terminal({ fontFamily: FONT, fontSize: S.fs, lineHeight: 1.1, cursorBlink: true, scrollback: 5000, theme: THEME, allowProposedApi: false, macOptionIsMeta: true, rightClickSelectsWord: false, convertEol: false }); const F = window.FitAddon && (window.FitAddon.FitAddon || window.FitAddon); if (typeof F === 'function') { t.fit = new F(); t.term.loadAddon(t.fit); } t.term.open(t.el); t.term.onData(d => input(t, d)); if (t.term.onBinary) t.term.onBinary(d => { if (t.ws && t.ws.readyState === 1) t.ws.send(Uint8Array.from(d, c => c.charCodeAt(0) & 255)); }); t.term.onResize(({ cols, rows }) => { if (t.ws && t.ws.readyState === 1) t.ws.send('r' + cols + ',' + rows); }); t.term.onTitleChange(title => { if (title) { t.title = title; paintTabs(); } }); t.term.attachCustomKeyEventHandler(ev => keyHandler(t, ev)); t.el.addEventListener('contextmenu', ev => { if (t.term.hasSelection()) { ev.preventDefault(); copySel(); } }); S.tabs.push(t); if (select !== false) pick(t); connect(t); return t; } function pick(t) { S.cur = t; S.tabs.forEach(x => x.el.classList.toggle('on', x === t)); paintTabs(); hideMsg(); if (t.state === 'kicked') showMsg('Este terminal foi aberto em outra janela ou aparelho.', [['Usar aqui', () => { hideMsg(); connect(t); }]]); requestAnimationFrame(() => { fit(); if (!coarse || document.activeElement === document.body) t.term.focus(); }); } function closeTab(t) { if (t.ws && t.ws.readyState === 1 && t.state === 'on') { try { t.ws.send('k'); } catch (e) { } } clearTimeout(t.timer); t.state = 'ended'; if (t.ws) { try { t.ws.close(); } catch (e) { } } t.term.dispose(); t.el.remove(); const i = S.tabs.indexOf(t); S.tabs.splice(i, 1); saveTabs(); if (S.cur === t) { if (S.tabs.length) pick(S.tabs[Math.max(0, i - 1)]); else { S.cur = null; newTab(); } } else paintTabs(); } /* ------------------------------------------------------------ conexão */ async function connect(t) { if (t.state === 'ended' || t.asking || (t.ws && t.ws.readyState <= 1)) return; clearTimeout(t.timer); t.state = 'connecting'; paintTabs(); let d; t.asking = true; try { d = await api('ticket', { sid: t.sid, cols: t.term.cols, rows: t.term.rows }); } catch (e) { t.asking = false; if (e.off) { fatal(e.message); return; } t.state = 'off'; paintTabs(); if (t === S.cur && (e.down || t.retry > 1)) showMsg(e.message, [['Tentar de novo', () => { hideMsg(); t.retry = 0; connect(t); }]]); return retryLater(t); } t.asking = false; if (t.state === 'ended') return; const u = new URL('term-ws?t=' + d.ticket, location.href); u.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws; try { ws = new WebSocket(u.href); } catch (e) { t.state = 'off'; return retryLater(t); } ws.binaryType = 'arraybuffer'; t.ws = ws; t.reattach = !!t.sid; ws.onmessage = ev => { if (t.ws !== ws) return; if (typeof ev.data === 'string') { let m; try { m = JSON.parse(ev.data); } catch (e) { return; } control(t, m); } else t.term.write(new Uint8Array(ev.data)); }; ws.onclose = () => { if (t.ws !== ws) return; t.ws = null; if (t.state === 'ended' || t.state === 'kicked') { paintTabs(); return; } const was = t.state; t.state = 'off'; paintTabs(); if (was === 'on') t.retry = 0; retryLater(t); }; } function retryLater(t) { clearTimeout(t.timer); if (t.state === 'ended' || t.state === 'kicked') return; const wait = [500, 1500, 3000, 5000, 10000][Math.min(t.retry++, 4)]; t.timer = setTimeout(() => { if (!document.hidden || t.retry < 3) connect(t); }, wait); } function control(t, m) { if (m.t === 'hello') { if (t.reattach) t.term.reset(); t.sid = m.sid; t.state = 'on'; t.retry = 0; saveTabs(); paintTabs(); if (t === S.cur) hideMsg(); if (t.term.cols && t.ws) t.ws.send('r' + t.term.cols + ',' + t.term.rows); const q = t.queue.splice(0); q.forEach(s => send(t, s)); } else if (m.t === 'exit') { t.state = 'ended'; t.sid = ''; saveTabs(); paintTabs(); t.term.write('\r\n\x1b[2m[sessão encerrada · tecle Enter para abrir outra]\x1b[0m\r\n'); } else if (m.t === 'gone') { t.ws = null; // o fechamento que vem a seguir não deve reconectar de novo t.sid = ''; t.reattach = false; t.term.reset(); t.term.write('\x1b[2m[a sessão anterior foi encerrada no servidor · abrindo uma nova]\x1b[0m\r\n'); t.state = 'off'; setTimeout(() => connect(t), 50); } else if (m.t === 'kicked') { t.state = 'kicked'; paintTabs(); if (t === S.cur) showMsg('Este terminal foi aberto em outra janela ou aparelho.', [['Usar aqui', () => { hideMsg(); t.state = 'off'; connect(t); }]]); } else if (m.t === 'error') { t.state = 'ended'; t.sid = ''; saveTabs(); paintTabs(); toast(m.msg || 'Erro no terminal', 'er'); t.term.write('\r\n\x1b[31m' + (m.msg || 'Erro') + '\x1b[0m\r\n'); } } function send(t, s) { if (t.ws && t.ws.readyState === 1 && t.state === 'on') t.ws.send('i' + s); else { t.queue.push(s); if (t.state === 'off') connect(t); } } /* ------------------------------------------------------------ teclado */ function input(t, d) { if (t.state === 'ended') { // Enter numa sessão encerrada abre outra no lugar if (d === '\r' || d === '\n') { t.state = 'off'; t.sid = ''; t.reattach = false; t.term.reset(); connect(t); } return; } if (S.ctrl && d.length === 1) { const c = d.toUpperCase().charCodeAt(0); if (c >= 64 && c <= 95) d = String.fromCharCode(c - 64); else if (d === '?') d = '\x7f'; else if (d === ' ') d = '\x00'; setMod('ctrl', false); } if (S.alt) { d = '\x1b' + d; setMod('alt', false); } send(t, d); } function keyHandler(t, ev) { if (ev.type !== 'keydown') return true; const k = ev.key.toLowerCase(); if (ev.ctrlKey && ev.shiftKey && k === 'c') { copySel(); ev.preventDefault(); return false; } if (ev.ctrlKey && ev.shiftKey && k === 'v') return false; // o navegador cola sozinho if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && k === 'c' && t.term.hasSelection()) { copySel(); ev.preventDefault(); return false; } return true; } function setMod(m, on) { S[m] = on; const b = qs('#xtKeys [data-k="' + m + '"]'); if (b) b.classList.toggle('on', on); } function key(k) { const t = S.cur; if (!t) return; if (k === 'ctrl' || k === 'alt') { setMod(k, !S[k]); t.term.focus(); return; } const app = t.term.modes && t.term.modes.applicationCursorKeysMode; const cur = c => (app ? '\x1bO' : '\x1b[') + c; const map = { esc: '\x1b', tab: '\t', up: cur('A'), down: cur('B'), right: cur('C'), left: cur('D'), home: '\x1b[H', end: '\x1b[F', pgup: '\x1b[5~', pgdn: '\x1b[6~', '^c': '\x03', '^d': '\x04', '^z': '\x1a' }; let d = map[k] != null ? map[k] : k; if (S.ctrl && k.length === 1) { input(t, k); t.term.focus(); return; } if (S.alt) { d = '\x1b' + d; setMod('alt', false); } if (S.ctrl) setMod('ctrl', false); if (t.state === 'ended') input(t, d); else send(t, d); t.term.focus(); } /* ------------------------------------------------------------ ações da barra */ function copySel() { const t = S.cur; if (!t) return; const s = t.term.getSelection(); if (!s) return toast('Selecione um texto no terminal primeiro'); U.copyText(s).then(() => { toast('Copiado'); t.term.clearSelection(); }); } async function pasteText() { const t = S.cur; if (!t) return; if (navigator.clipboard && navigator.clipboard.readText && window.isSecureContext) { try { const s = await navigator.clipboard.readText(); if (s) { t.term.paste(s); t.term.focus(); } return; } catch (e) { } } const r = await U.dialog({ title: 'Colar no terminal', icon: 'paste', wide: true, html: '
Cole o texto aqui (Ctrl+V ou segure e toque em Colar):
', buttons: [{ label: 'Cancelar', value: null }, { label: 'Colar', value: 'ok', kind: 'pri' }], validate(ov) { const v = qs('textarea', ov).value; return v ? { v } : false; } }); if (r && r.v) { t.term.paste(r.v); t.term.focus(); } } function setFont(d) { S.fs = Math.max(9, Math.min(24, S.fs + d)); ls.set('fs', S.fs); S.tabs.forEach(t => { t.term.options.fontSize = S.fs; }); fitSoon(); } function toggleFull(on) { S.full = on == null ? !S.full : on; $('xtBox').classList.toggle('xt-full', S.full); document.body.classList.toggle('xt-noscroll', S.full); $('xtFullBtn').innerHTML = svg(S.full ? 'shrink' : 'expand'); $('xtFullBtn').title = S.full ? 'Sair da tela cheia' : 'Tela cheia'; fitSoon(); if (S.cur) S.cur.term.focus(); } function onBar(e) { const tb = e.target.closest('.xt-tab'); const cl = e.target.closest('[data-close]'); if (cl) { const t = S.tabs[+cl.dataset.close]; if (!t) return; if (t.state === 'on' || t.state === 'off') { U.confirmDlg('Fechar este terminal?', 'O que estiver rodando nele será encerrado.', 'Fechar', true).then(ok => { if (ok) closeTab(t); }); } else closeTab(t); return; } if (tb) { const t = S.tabs[+tb.dataset.i]; if (t && t !== S.cur) pick(t); else if (t) t.term.focus(); return; } const b = e.target.closest('[data-x]'); if (!b) return; const r = b.getBoundingClientRect(); switch (b.dataset.x) { case 'new': if (S.tabs.length >= 8) return toast('Máximo de 8 terminais abertos', 'er'); newTab(); break; case 'copy': copySel(); break; case 'paste': pasteText(); break; case 'font-': setFont(-1); break; case 'font+': setFont(1); break; case 'full': toggleFull(); break; case 'quick': U.menu(r.left, r.bottom + 4, QUICK.map(c => ({ l: c, i: 'term', fn: () => run(c) }))); break; case 'more': U.menu(r.right - 230, r.bottom + 4, [ { l: 'Limpar a tela', i: 'refresh', fn: () => { if (S.cur) { S.cur.term.clear(); S.cur.term.focus(); } } }, { l: 'Reconectar', i: 'refresh', fn: () => { const t = S.cur; if (!t) return; hideMsg(); if (t.ws) { try { t.ws.close(); } catch (er) { } } t.state = 'off'; t.retry = 0; setTimeout(() => connect(t), 100); } }, '-', { l: 'Modo simples (o terminal antigo)', i: 'term', fn: () => setMode('simple') }, '-', { l: 'Fechar todos os terminais', i: 'trash', danger: true, fn: closeAll } ]); break; } } async function closeAll() { if (!(await U.confirmDlg('Fechar todos os terminais?', 'Todas as sessões abertas no servidor serão encerradas.', 'Fechar todos', true))) return; S.tabs.slice().forEach(t => { if (t.ws && t.ws.readyState === 1) { try { t.ws.send('k'); } catch (e) { } } t.state = 'ended'; }); S.tabs.slice().forEach(t => { if (t.ws) try { t.ws.close(); } catch (e) { } t.term.dispose(); t.el.remove(); }); S.tabs = []; S.cur = null; saveTabs(); newTab(); } /* ------------------------------------------------------------ mensagens */ function showMsg(text, btns) { const m = $('xtMsg'); if (!m) return; m.innerHTML = '

' + E(text) + '

' + (btns || []).map((b, i) => '').join(' ') + '
'; m.hidden = false; qsa('[data-m]', m).forEach(b => b.onclick = () => btns[+b.dataset.m][1]()); } function hideMsg() { const m = $('xtMsg'); if (m) m.hidden = true; } function fatal(msg) { S.error = msg; root.innerHTML = '
Terminal completo indisponível

' + E(msg) + '

'; $('xtUseSimple').onclick = () => setMode('simple'); S.built = false; S.tabs = []; S.cur = null; } /* ------------------------------------------------------------ modo (completo / simples) */ function setMode(m) { S.mode = m; ls.set('mode', m); if (m === 'full') { S.error = ''; } show(); } function applyMode() { const full = S.mode === 'full' && canFull(); $('xt').hidden = !full; $('tSimple').hidden = full; const b = $('tFull'); if (b) { b.hidden = !canFull(); b.onclick = () => setMode('full'); } return full; } async function start() { if (S.built || S.error) return; root = $('xt'); root.innerHTML = '
Abrindo o terminal…
'; try { await loadLib(); } catch (e) { fatal(e.message); return; } build(); let sids = ls.get('sids', []); if (!Array.isArray(sids)) sids = []; let server = []; try { server = (await api('list')).sessions; } catch (e) { if (e.off) { fatal(e.message); return; } } const alive = new Set(server.map(s => s.id)); // as abas desta janela + as que ficaram abertas sem ninguém olhando (ex.: começou no PC e abriu no celular) const want = sids.filter(id => alive.has(id)); server.forEach(s => { if (!s.attached && !want.includes(s.id)) want.push(s.id); }); if (want.length) want.forEach((id, i) => newTab(id, i === 0)); else newTab(); pending.splice(0).forEach(run); } const pending = []; function run(cmd) { if (!S.built) { pending.push(cmd); return; } let t = S.cur || newTab(); if (t.state === 'ended') { t.state = 'off'; t.sid = ''; t.reattach = false; t.term.reset(); connect(t); } send(t, cmd + '\r'); t.term.focus(); } function show() { visible = true; if (!applyMode()) { setTimeout(() => $('tIn') && $('tIn').focus(), 50); return; } if (!S.built) { start(); return; } requestAnimationFrame(() => { fit(); if (S.cur) S.cur.term.focus(); }); S.tabs.forEach(t => { if (t.state === 'off') connect(t); }); } function hide() { visible = false; if (S.full) toggleFull(false); } window.TERM = { show, hide, active: () => active(), run(cmd) { if (!visible) location.hash = '#terminal'; run(cmd); } }; if ((location.hash || '') === '#terminal') show(); })(); LKM_EOF_TERMJS cat > "$DIR/sys.js" <<'LKM_EOF_SYSJS' /* LK Monitor - ações do sistema no Painel: serviços (iniciar/parar/reiniciar), encerrar processo, modo servidor ↔ modo com tela, reiniciar e desligar o PC. Tudo passa por ?sys= (login + token) e vai para o cmd.log. */ (function () { 'use strict'; const U = window.UI; if (!U) return; const { E, qs, toast, dialog, menu, svg, IC } = U; const C0 = typeof CFG !== 'undefined' ? CFG : {}; const TOKEN = C0.token || ''; const ROOT = C0.user === 'root'; const NEED_ROOT = 'Precisa do terminal do LK Monitor como root (sudo lk › 33 › instalar / atualizar e escolha root).'; const PANEL = n => n === 'apache2' || /^php\d+\.\d+-fpm$/.test(n); const CRITICAL = ['systemd', 'init', 'sshd', 'dbus-daemon', 'systemd-journal', 'systemd-logind', 'apache2', 'php-fpm', 'mariadbd', 'mysqld', 'dockerd', 'containerd', 'Xorg', 'gnome-shell', 'gdm', 'gdm3']; let MODE = null, busy = false; Object.assign(IC, { play: '', stop: '', restart: '', monitor: '', srv: '', skull: '' }); async function api(act, body) { let r; try { r = await fetch('?sys=' + act, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-LKM-Token': TOKEN }, body: JSON.stringify(body || {}) }); } catch (e) { throw new Error('Sem conexão com o painel'); } if (r.status === 401) { location.reload(); throw new Error('Sessão expirada'); } let d; try { d = await r.json(); } catch (e) { throw new Error('Resposta inválida do servidor (' + r.status + ')'); } if (!d.ok) throw new Error(d.error || 'Erro desconhecido'); return d; } const refresh = () => { if (typeof tick === 'function') setTimeout(tick, 400); }; const showErr = (title, e) => dialog({ title, icon: 'info', html: '
' + E(e.message || String(e)) + '
', buttons: [{ label: 'Fechar', value: null }] }); /* ------------------------------------------------------------ serviços */ async function service(name, op) { const verb = { start: 'Iniciar', stop: 'Parar', restart: 'Reiniciar' }[op]; if (op === 'stop' || (op === 'restart' && name === 'ssh')) { const warn = name === 'ssh' ? '

Quem estiver conectado por SSH pode cair.

' : name === 'ufw' ? '

Sem o ufw o firewall fica desligado.

' : /mariadb|mysql/.test(name) ? '

Os sites que usam banco de dados vão dar erro enquanto estiver parado.

' : ''; const ok = await dialog({ title: verb + ' ' + name + '?', icon: 'power', html: '

O serviço ' + E(name) + ' vai ' + (op === 'stop' ? 'parar' : 'reiniciar') + '.

' + warn, buttons: [{ label: 'Cancelar', value: null }, { label: verb, value: true, kind: op === 'stop' ? 'danger' : 'pri' }] }); if (!ok) return; } toast(verb + ' ' + name + '…'); try { const d = await api('service', { name, op }); toast(d.msg); refresh(); if (d.delayed) setTimeout(refresh, 4000); } catch (e) { showErr('Não foi possível ' + verb.toLowerCase() + ' ' + name, e); } } document.addEventListener('click', e => { const b = e.target.closest('#svcs .svc[data-svc]'); if (!b) return; e.preventDefault(); const name = b.dataset.svc, on = b.dataset.st === 'active', r = b.getBoundingClientRect(); const ent = [ { l: 'Iniciar', i: 'play', dis: on || !ROOT, fn: () => service(name, 'start') }, { l: 'Reiniciar', i: 'restart', dis: !ROOT, fn: () => service(name, 'restart') }, { l: PANEL(name) ? 'Parar (desligaria o painel)' : 'Parar', i: 'stop', danger: true, dis: !on || !ROOT || PANEL(name), fn: () => service(name, 'stop') } ]; if (!ROOT) ent.push('-', { l: 'Precisa de root', i: 'lock', dis: true, fn() { } }); menu(r.left, r.bottom + 4, ent); }); /* ------------------------------------------------------------ processos */ document.addEventListener('click', async e => { const b = e.target.closest('#procs .pk'); if (!b) return; if (!ROOT) return toast(NEED_ROOT, 'er'); const pid = +b.dataset.pid, name = b.dataset.name, user = b.dataset.user; const crit = CRITICAL.some(c => name === c || name.startsWith(c)); const r = await dialog({ title: 'Encerrar ' + name + '?', icon: 'trash', html: '

Processo ' + E(name) + ' · PID ' + pid + ' · usuário ' + E(user) + '

' + (crit ? '

Este é um processo do sistema. Encerrar pode derrubar serviços, o SSH ou o próprio painel.

' : '') + '

Encerrar pede para o programa fechar (SIGTERM). Forçar mata na hora, sem salvar nada (kill -9).

', buttons: [{ label: 'Cancelar', value: null }, { label: 'Forçar', value: 'KILL' }, { label: 'Encerrar', value: 'TERM', kind: 'danger' }] }); if (!r) return; try { const d = await api('kill', { pid, name, sig: r }); toast(d.msg); refresh(); } catch (er) { showErr('Não foi possível encerrar ' + name, er); } }); /* ------------------------------------------------------------ modo servidor e energia */ function paint() { const box = qs('#sysAct'), lab = qs('#sysMode'); if (!box) return; const m = MODE; if (lab) lab.textContent = !m ? '' : m.server ? (m.desktop_now ? 'modo servidor · tela ainda ligada' : 'modo servidor') : (m.desktop_now ? 'com área de trabalho' : 'normal · tela desligada agora'); const dis = !ROOT || busy ? ' disabled' : ''; const tip = ROOT ? '' : ' title="' + E(NEED_ROOT) + '"'; const modeBtn = !m ? '' : m.server ? '' : ''; box.innerHTML = modeBtn + '' + '' + (busy ? '' + E(busy) + '' : ''); } async function loadMode() { try { MODE = (await api('status')).mode; } catch (e) { MODE = null; } paint(); } async function setMode(to) { const srv = to === 'server'; const html = (srv ? '

Desliga a área de trabalho (fica só o servidor), a tela apaga sozinha depois de 1 minuto, fechar a tampa não suspende e a suspensão fica bloqueada. Igual à opção 35 do sudo lk.

' : '

A área de trabalho volta a ligar, a tela, a tampa e a suspensão voltam ao padrão do sistema.

') + '
Quando aplicar
' + '
' + '

' + (srv ? 'A tela do notebook vai para o modo texto. Programas abertos na área de trabalho são fechados.' : 'A tela de login aparece no notebook.') + ' O painel continua no ar.

' + '' + ''; const r = await dialog({ title: srv ? 'Ativar modo servidor' : 'Voltar ao modo com tela', icon: 'server', html, buttons: [{ label: 'Cancelar', value: null }, { label: 'Aplicar', value: 'ok', kind: 'pri' }], onOpen(ov) { ov.dataset.w = 'agora'; ov.querySelectorAll('.sy-when button').forEach(b => b.onclick = () => { ov.dataset.w = b.dataset.w; ov.querySelectorAll('.sy-when button').forEach(x => x.classList.toggle('on', x === b)); ov.querySelectorAll('.sy-note[data-n]').forEach(n => n.hidden = n.dataset.n !== b.dataset.w); }); }, validate(ov) { return { when: ov.dataset.w }; } }); if (!r) return; busy = 'Configurando… pode levar até 1 minuto'; paint(); try { const d = await api('mode', { to, when: r.when }); if (d.mode) MODE = d.mode; toast(d.msg); if (r.when === 'agora') setTimeout(loadMode, 6000); } catch (e) { showErr('Falha ao trocar o modo', e); } busy = false; paint(); } async function power(op) { const off = op === 'poweroff'; const ok = await dialog({ title: off ? 'Desligar o PC?' : 'Reiniciar o PC?', icon: 'power', html: off ? '

O servidor ' + E(C0.host || '') + ' vai desligar. Todos os sites e o painel saem do ar.

Para ligar de novo, alguém precisa apertar o botão de ligar no PC.

' : '

O servidor ' + E(C0.host || '') + ' vai reiniciar. Os sites e o painel ficam fora do ar por 1 ou 2 minutos.

', buttons: [{ label: 'Cancelar', value: null }, { label: off ? 'Desligar' : 'Reiniciar', value: true, kind: 'danger' }] }); if (!ok) return; try { const d = await api('power', { op }); busy = off ? 'Desligando…' : 'Reiniciando… o painel volta sozinho'; paint(); toast(d.msg); if (!off) waitBack(); } catch (e) { showErr('Não foi possível ' + (off ? 'desligar' : 'reiniciar'), e); } } /* depois de reiniciar: espera o painel cair e voltar, então recarrega */ function waitBack() { let down = false, n = 0; const t = setInterval(async () => { if (++n > 120) { clearInterval(t); busy = false; paint(); return; } try { const r = await fetch('?api=1', { cache: 'no-store', credentials: 'same-origin' }); if (down && (r.ok || r.status === 401)) { clearInterval(t); location.reload(); } } catch (e) { down = true; } }, 5000); } document.addEventListener('click', e => { const b = e.target.closest('#sysAct button[data-a]'); if (!b || b.disabled) return; const a = b.dataset.a; if (a === 'server' || a === 'desktop') setMode(a); else power(a); }); /* o menu do fm.js só fecha sozinho depois que a aba Arquivos abre: fecha aqui também */ document.addEventListener('mousedown', e => { if (!e.target.closest('#fmMenu')) U.closeMenu(); }, true); document.addEventListener('keydown', e => { if (e.key === 'Escape') U.closeMenu(); }); window.addEventListener('scroll', () => U.closeMenu(), true); window.addEventListener('blur', () => U.closeMenu()); paint(); loadMode(); })(); LKM_EOF_SYSJS cat > "$DIR/apps.js" <<'LKM_EOF_APPSJS' /* LK Monitor - aba Apps (App Store em tabela, estilo clássico) e a grade "Software" + números da visão geral do Painel. As instalações rodam como tarefa em segundo plano (job.php) e o log aparece ao vivo (window.JOBS). */ (function () { 'use strict'; const U = window.UI; if (!U) return; const { E, qs, toast, dialog, menu, svg } = U; const C0 = typeof CFG !== 'undefined' ? CFG : {}; const TOKEN = C0.token || ''; const S = { data: null, error: '', cat: 'all', q: '', loading: false }; let root, inited = false, poll = null; const ls = { get(k, d) { try { const v = localStorage.getItem('lkm.apps.' + k); return v === null ? d : v; } catch (e) { return d; } }, set(k, v) { try { localStorage.setItem('lkm.apps.' + k, v); } catch (e) { } } }; S.cat = ls.get('cat', 'all'); async function post(route, act, body) { let r; try { r = await fetch('?' + route + '=' + act, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-LKM-Token': TOKEN }, body: JSON.stringify(body || {}) }); } catch (e) { throw new Error('Sem conexão com o painel'); } if (r.status === 401) { location.reload(); throw new Error('Sessão expirada'); } let d; try { d = await r.json(); } catch (e) { throw new Error('Resposta inválida do servidor (' + r.status + ')'); } if (!d.ok) { const er = new Error(d.error || 'Erro desconhecido'); er.data = d; throw er; } return d; } const api = (act, body) => post('apps', act, body); const showErr = (title, e) => dialog({ title, icon: 'info', html: '
' + E(e.message || String(e)) + '
', buttons: [{ label: 'Fechar', value: null }] }); const byId = id => (S.data && S.data.apps.find(a => a.id === id)) || null; const url = a => a.path ? 'http://' + location.hostname + a.path : a.port ? (a.https ? 'https://' : 'http://') + location.hostname + ':' + a.port + '/' : null; const initials = n => n.replace(/[^A-Za-z0-9 ]/g, ' ').split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?'; const logo = (a, cls) => a.icon ? '' : '' + E(initials(a.name)) + ''; const rw = () => S.data && S.data.root && C0.user === 'root'; /* ------------------------------------------------------------ montagem */ function build() { root = qs('#apps'); root.innerHTML = '
' + '
' + '
' + '
NomeDescriçãoStatusAção
'; qs('#apQ').oninput = e => { S.q = e.target.value.trim().toLowerCase(); render(); }; qs('#apRf').onclick = () => load(); qs('#apCats').onclick = e => { const b = e.target.closest('button[data-c]'); if (!b) return; S.cat = b.dataset.c; ls.set('cat', S.cat); render(); }; qs('#apGrid').onclick = onClick; qs('#apMsg').onclick = onClick; } async function load(quiet) { if (S.loading) return; S.loading = true; if (!quiet && !S.data && root) qs('#apGrid').innerHTML = 'Carregando…'; try { S.data = await api('list'); S.error = ''; } catch (e) { S.error = e.message; } S.loading = false; render(); homeAt = Date.now(); paintHome(); clearTimeout(poll); if (S.data && S.data.busy && location.hash === '#apps') poll = setTimeout(() => load(true), 4000); } function render() { if (!root) return; const d = S.data, msg = qs('#apMsg'); msg.innerHTML = S.error ? '
' + svg('info') + '
' + E(S.error) + '
' : ''; if (!d) { if (S.error) qs('#apGrid').innerHTML = ''; return; } if (!rw()) msg.innerHTML += '
' + svg('lock') + '
Só para ver. Para instalar e desinstalar, o terminal do LK Monitor precisa ser root: no servidor rode sudo lk › opção 33 › instalar / atualizar e escolha root.
'; if (d.busy) msg.innerHTML += '
' + E(d.busy.title) + ' em andamento…
'; const n = d.apps.filter(a => a.installed).length; qs('#apCount').textContent = n + ' de ' + d.apps.length + ' instalados'; const cats = [['all', 'Todos'], ['inst', 'Instalados']].concat(Object.entries(d.cats)); if (!cats.some(c => c[0] === S.cat)) S.cat = 'all'; qs('#apCats').innerHTML = cats.map(([k, l]) => '').join(''); const list = d.apps.filter(a => (S.cat === 'all' || (S.cat === 'inst' ? a.installed : a.cat === S.cat)) && (!S.q || (a.name + ' ' + a.desc).toLowerCase().includes(S.q))); qs('#apGrid').innerHTML = list.length ? list.map(row).join('') : 'Nenhum app encontrado.'; } function row(a) { const d = S.data, ok = rw(); const mine = d.busy && d.busy.app === a.id, other = d.busy && !mine; let st; if (mine) st = '' + (a.job && a.job.op === 'remove' ? 'Removendo' : 'Instalando') + ''; else if (!a.installed) st = 'Não instalado'; else if (a.state === 'active') st = 'Rodando'; else if (a.state && a.state !== 'unknown') st = '' + ({ inactive: 'Parado', exited: 'Parado', failed: 'Com erro', activating: 'Iniciando', created: 'Parado' }[a.state] || E(a.state)) + ''; else st = 'Instalado'; const need = a.needs ? d.apps.find(x => x.id === a.needs) : null; const tags = []; if (a.docker) tags.push('Docker'); if (a.port) tags.push(':' + a.port + ''); if (!a.installed && need && !a.needs_ok) tags.push('requer ' + E(need.name) + ''); if (a.job && a.job.state === 'error' && !mine) tags.push(''); const acts = []; const sep = '|'; if (mine) acts.push(''); else if (!a.installed) { const dis = !ok || other || !a.needs_ok; const why = !ok ? 'Precisa de root' : other ? 'Espere a outra instalação terminar' : !a.needs_ok ? 'Instale ' + (need ? need.name : a.needs) + ' antes' : ''; acts.push(''); } else { const u = url(a); if (u) acts.push('Abrir'); if (a.svc || a.docker) acts.push(a.state === 'active' ? '' : ''); acts.push(''); } return '' + '
' + logo(a, 'ap-logo') + '
' + E(a.name) + '' + (a.version ? 'v' + E(a.version) : '—') + '
' + '
' + E(a.desc) + '
' + (a.installed && a.note ? '
' + E(a.note) + '
' : '') + (tags.length ? '
' + tags.join('') + '
' : '') + '' + '' + st + '' + acts.join(sep) + ''; } /* ------------------------------------------------------------ Painel: grade "Software" e números */ let homeBusy = false, homeAt = 0; async function count(route, act, pick, id) { const el = qs('#' + id); if (!el) return; try { el.textContent = pick(await post(route, act)); } catch (e) { el.textContent = '–'; el.title = e.message; } } async function home(force) { const soft = qs('#soft'); if (!soft || homeBusy) return; if (!force && S.data && Date.now() - homeAt < 5000) return paintHome(); homeBusy = true; try { if (!S.data || force) S.data = await api('list'); homeAt = Date.now(); paintHome(); } catch (e) { soft.innerHTML = '
' + E(e.message) + '
'; } homeBusy = false; } function paintHome() { const soft = qs('#soft'), d = S.data; if (!soft || !d) return; const inst = d.apps.filter(a => a.installed); const ov = qs('#ovApps'); if (ov) ov.textContent = inst.length; soft.innerHTML = inst.length ? inst.map(a => '' + (a.state && a.state !== 'unknown' ? '' : '') + logo(a, '') + '' + E(a.name) + '').join('') : '
Nenhum app instalado ainda. Abra a App Store.
'; } document.addEventListener('click', e => { const a = e.target.closest('#soft a[data-cat]'); if (a) { S.cat = 'inst'; ls.set('cat', 'inst'); } }); function homeAll() { if (!qs('#soft')) return; home(true); count('sites', 'list', d => d.sites.length, 'ovSites'); count('db', 'overview', d => d.mysql && d.mysql.ok ? d.dbs.length : '–', 'ovDbs'); } /* ------------------------------------------------------------ ações */ async function watch(id, title) { if (!window.JOBS) return toast('Atualize a página', 'er'); await window.JOBS.view(id, title || 'App Store', 'download'); load(true); } async function start(a, op) { const rm = op === 'remove'; if (rm) { const deps = S.data.apps.filter(x => x.installed && x.needs === a.id); const html = '

' + E(a.name) + ' será removido do servidor.

' + (a.docker ? '

O container é apagado, mas os dados ficam guardados nos volumes do Docker. Se instalar de novo, volta como estava.

' : '') + (deps.length ? '

Estes apps usam ' + E(a.name) + ' e vão parar de funcionar: ' + deps.map(x => E(x.name)).join(', ') + '.

' : '') + (a.id === 'node' ? '

Os apps Node do PM2 param de funcionar.

' : '') + (a.id === 'composer' ? '

O deploy dos sites Laravel usa o Composer.

' : ''); const ok = await dialog({ title: 'Desinstalar ' + a.name + '?', icon: 'trash', html, buttons: [{ label: 'Cancelar', value: null }, { label: 'Desinstalar', value: true, kind: 'danger' }] }); if (!ok) return; } try { const d = await api(op, { id: a.id }); load(true); await watch(d.job, (rm ? 'Desinstalar ' : 'Instalar ') + a.name); } catch (e) { if (e.data && e.data.running) return watch(e.data.running); showErr('Não foi possível ' + (rm ? 'desinstalar ' : 'instalar ') + a.name, e); } } async function svc(a, op) { if (op === 'stop') { const ok = await dialog({ title: 'Parar ' + a.name + '?', icon: 'power', html: '

' + E(a.name) + ' vai parar até você iniciar de novo (ou até o PC reiniciar).

', buttons: [{ label: 'Cancelar', value: null }, { label: 'Parar', value: true, kind: 'danger' }] }); if (!ok) return; } toast({ start: 'Iniciando ', stop: 'Parando ', restart: 'Reiniciando ' }[op] + a.name + '…'); try { const d = await api('svc', { id: a.id, op }); toast(d.msg); load(true); } catch (e) { showErr('Falha em ' + a.name, e); } } function onClick(e) { const b = e.target.closest('[data-a]'); if (!b || b.disabled) return; const c = b.closest('.ap-c'), a = c ? byId(c.dataset.id) : null; const act = b.dataset.a; if (act === 'watch') return watch(b.dataset.j, a ? a.name : null); if (!a) return; if (act === 'install') return start(a, 'install'); if (act === 'start' || act === 'restart') return svc(a, act); if (act === 'more') { const ok = rw(), r = b.getBoundingClientRect(), ent = []; if (a.svc || a.docker) { ent.push({ l: 'Iniciar', i: 'power', dis: !ok || a.state === 'active', fn: () => svc(a, 'start') }); ent.push({ l: 'Reiniciar', i: 'refresh', dis: !ok, fn: () => svc(a, 'restart') }); ent.push({ l: 'Parar', i: 'x', dis: !ok || a.state !== 'active', fn: () => svc(a, 'stop') }, '-'); } const u = url(a); if (u) ent.push({ l: 'Copiar endereço', i: 'link', fn: () => U.copyText(u).then(() => toast('Endereço copiado')) }); if (a.job) ent.push({ l: 'Ver último log', i: 'log', fn: () => watch(a.job.id, a.name) }); if (ent.length && ent[ent.length - 1] !== '-') ent.push('-'); ent.push({ l: a.protect ? 'Desinstalar (bloqueado)' : 'Desinstalar', i: 'trash', danger: true, dis: !ok || !!a.protect || !!S.data.busy, fn: () => start(a, 'remove') }); if (a.protect) ent.push({ l: a.protect.length > 46 ? a.protect.slice(0, 44) + '…' : a.protect, i: 'lock', dis: true, fn() { } }); menu(Math.max(6, r.right - 230), r.bottom + 4, ent); } } window.APPS = { show() { if (!inited) { build(); inited = true; } load(); }, home: homeAll }; window.addEventListener('hashchange', () => { if (location.hash !== '#apps') clearTimeout(poll); }); if (location.hash === '#apps') window.APPS.show(); homeAll(); setInterval(() => { if (!document.hidden && qs('#v-painel.on')) homeAll(); }, 300000); })(); LKM_EOF_APPSJS rm -rf "$DIR/appicons"; mkdir -p "$DIR/appicons" base64 -d > "$DIR/appicons/certbot.png" <<'LKM_EOF_DIR_0' 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 AAAASUVORK5CYII= LKM_EOF_DIR_0 base64 -d > "$DIR/appicons/cockpit.png" <<'LKM_EOF_DIR_1' iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAZRElEQVR42u1deXhURbavqnt7TSfp ztZZSUgIq7LDAAojjKLDoDAiCgLKoozPceaNCuOM8lyeOjOKijq+N08FAVlGERwHHVFAXECQfRkD kQBJyL50tk7v91a9P0735XZ3gKT7didg18eHbX9N963zO/WrU+ecOgejxYdQbHTfIDERxACIARAb MQBiAMRGDIAYALERAyAGQGzEAIgBEBsxAGIAxEYMgBgAsRH5wfecR8EYEYwxQgJl0psGDSlI1QzM 0F2TpStM0+SY1OYEPkHH6dVEzWGOYJEyt8jsbtrmEOvahPNN7pJ6Z3Gts6jaebreaXPRC1MlmCFE GWOsBwGAe0JABmPEYSzJXcXhoTn6n/Y1XN/HMCRbn21S8QR39TtFyiqaPScq7bvPtH9zuv1Ypd0t MAkJscfA0M0AEIwwxiJl8Hp8YfyM4cZbBiUWpmnkH6MMgcAwxtiHmXyANBm68LEAyM7Uuz4rat1y pOWbEisAzRHMGKPsxwqAXPRZRtXs0UnzfpI8OFsnCVRkDPskjnEoPwGwMYQ4CTeETlQ6NhxoWr/f Ut3i6QkwdAMAwPUg+n5m7UMTU+f8JNmk5yS5kyD9vdhgrLPYUIYoYxISTTZh44Gm//mqobjW2b2k FG0AYNtECOUlq5dOTp8/LlmvJgghgV5U7ox5uYV5nzhwWVz2A8FIwKbi8NA1ey0vfF5bbnHLn+3q BEBSfJ2KPHKT+dGbzKD1AmVyigiQFEaIu/hyuPQKECljCHWIKyw1gKHJJry0o27Fznqnh3IER9lM ihIABCPQrRsHJLwyM/vaLN3FRB/MQpShMourpM5VXOs81+iqavFY2gWrS3QLTKCMJ1jN43gNl2zg s4yq3imafmZNX7M2L1ktIRfAPx3CcLzS8cgHFbuKrfKnvUoAgKWt4fHz07Mevcl8MdHDnilJrdzi 3n2mfeeptoNltjMNLsmI7MxQ87ggVTMyN+5n/eMnFBp6p2ikNRFsIMlheHlH3RMfVbkEFjU6ijgA PMECZQMztKvn543Oi4NJkSDRS282WIWPjrVsPtK850y73U3la4hgDLYm8pmbMn7DwP4IIepv1ehU 5Lo+hpkjTNOGJJoTVAE/F/wMB8ts89eUnaxxwpNfwQBIpD9juGnlvFyjngPGCNA+6tP6YxX2N79p 3HK0ucEqSEsHw9nVZ+l3/qcxQgRjhpCkyMlx/B0jTA9MSBmao4fVQIJWITxhi128b135liPNUdgS IgWAJP0lk83LZ2TDhAO2U+mdI+ftL22v23S4GYQFbyo1c+xbOtKX3zHcuHRy+ohc/aWf6vdbKpdv r4s0BhwasTgS0scIUYZW3Jnz5C8yRMowwkQ2TzAcOYKrWjyPbal6cOP5E1UOxiR3DVJ2wox57SWe YJGyomrnqm8tVS3uYTl6o56nDDGZzUowhnV586BEo57f9n0rwT52uyIAAI2jDL01N/fXN6QG77ci ZYRgjNHKPY13vXXu65J2yuBEGnHbQ3JCiJQdPm/fsL8pUcePzNVj7GUkmQJhkbFxBYYso3rriVaO 4CsDAHCriZS9NTf3/vEpQKly6cM71S2eee+ULd9eZ3NTnmDFVf6yCwKsA6uLfnKi9WiFY0JhfKKO E/wxIBgLlI3Ki8syqrceb4HV2dMBAMthxZ05v74h1SMyFYeDrb0dp9qmvnHmYLndx/Xd44ShPlI6 Vet871DT4GxdYZoW2FJORx6RjcqLAy4CXem5AID0l0w2P/mLDIEGSh8cAH/9sn7OqtI2J+W749zf IQw8wa0Ocf3+JpOeH5tvoMwPA45ggbJxBQabm+450644BooBANKfPtS46p68AN6Xttwlmyuf3FpN MMIYiT0mKkIZIhhhhLZ932Zz05sHJTJ/jzfsB7cMSjxe6ThZ44TtqmeFJAlGAmX9zNq1C/IoQ8Rf +pQxgtGv1pe/vKNOsnN61ABDiCf4pe11i9eXE+xnBEtmxdoFeX3NWpEyBbdkoozRibGax3+/v3eC lmPswvMB73MEL15f/tbuRhWHBdqzIoJykgQD4e3djYvXl3P+DmqCEWMsQcv9/b7eah7jjryH3UZB YNW9OCP79mGmgLMu7LpLNle+8WWDisMesUfK3n8pqDh8sMze7qK3DEoEt6C0IQuUZRnVejX5vKhN KSIKFwCQ/g1949+cmxtA/QDG67vqn9xaHR2/ioLb8rdn25Pi+LH5Bj/bFGGRsev7GHb9YC2zuBXB gIRJPowxrYr8bU4v0BFJ+iJlPME7T7X95/sVsJzRlTOANn+3qeKLYqvcWoMTPkLo/+bkalUEhRor VQwA2JoevcncP10ryLYm2Idr2zxzVpUCSFeU/MF1wRBCc1aV1rZ5YJpyq3RghvbhG9Pkh+duAABM hWyT+rGbzZQhDsutfoYxWrS2vN4qcBjTK0r6kg5xGNe1eRatLQcdusC6GFOG/nhLepZRRVm4FhEJ g38wY2jZlPR4LUcZk5MPR/DKPY2fft/ao6gf464xBuxhn37fuurbRs6fiChj8Vpu2S8yGENh2kMk HPUvSNXMH5ciV3/KEMa4ptXz+y2V8JmeIHqOYNgwGUMc6YLEQMF/v6Wqts2D5USEMWVo/tjk/BRN mIuAhKP+j9xo1vBYrv5wCFj2z+pmu0h6APkQjAhGImVgFOjVROzKQQT0qckmLPuomsiICBaBVkUe uckc5iIgoc1KpMycoJo7JonJ1B/I53ilY+0+C5yNu13xKUOUoQmFhnfuzSt6euDpZ695a26uVkU6 T0dw7l2zz3Ki0iEnIg5jxtC8MUnmBJVIWcgQhAYARgjdMyYpQcuJLPC3n/64GmLf3S59kbKCVM3m X+V/vaTfgnHJfc3aLKPq/vEpd400MX+r4bLLXaTsmU+qA3YUkbEELTdvTBLgESUA4Id5guePS5HA kNT/RKVj6/EWWCJhpCwinuBwiBWM95kjTAf+2H/GcBNlSKCMMgSZLFKSROfzfAlG/zze+u8qv0UA c18wLgUOOqFBQEJQf8bQ2IK4gRla8CPKx2u76uEQEE76EPPJC0TZJSQAPIGypZPNmxbnJ8XxcECB 7+EI4gmGdMSuzlqk7PVd9cFPOzBDOzY/joU6666vAIQQQneOMIGRIJ1cOILrrcLmw81wkgxNbSlD s0YlHXx8wFNTM4b30oMt2HkkpDT3J6dmvDgjW6QM/ArSQxKM25ziF8VtXX1I+PAHh5sbrILcAwES uGukCYUaNu4yAAJlah5PuTbRj38YQwhtPtzc5hRD85DApv3LYcb1C/NG5uqfvjXz8BMDDj4+YNmU DMgiASQuwU6S9J++NfOZWzODk02BJdbstQQIsZNnY47gVoe45aifhoEEplybCI7eKG3Cg7N0+Ska JuMfeI73DzWFyIMYMYR+Ocz49/vywekIkxmZq392WubRZQMOPj7giSkZg7N1EjsFICFJ/5lbM5+a mhGcCQDEaLEJf9pWG3Cy7RK/vX+wWa55BCPGUH6KRkqsj8YVpYn94r2uZoyliFKZxb2/1MYYooh1 2aOHULyGe+mObA2P3QJT81iKo0Egc2SufmSu/r9vyzxQatt6ouWTE63/rnII3rsYXgtEoOzZaZnL pmQEp38h3/c8uPF8XZsntLRDCNHsO9debnHnJqul/Q/kMLFf/OFye5RWwPjCeDnlAQ/uKm6DlMqu 6hZ83uoSJ758evvJNjXvTZLAGMHmCRjDXjomP+5P07OO/dfAfY/1f+zm9IEZWlgTAmXP+aQfbBF6 RAbRrk2HmkOORQMLuQT25Q9W+f4HPza+jyFKK0CvJoOzdPJlCP/Zccoa8kYE4j7f5L7l9ZLnpmU9 /vP0gJw1SAyVr4kx+XFj8uOen555oMz+3sGmHJN6yWRzh7oPyRnvfmdZuqWSD88x7ptp2/xxydg/ LXVwtl6nIg4PjTgAhWmaLJNKcoWDXnhEdrjcJteLEDAA0T3xUdWBUtvb83JT4/kAgV7I+JQhMTY/ bmx+nBRI6VD6G/Y33bu6jGAU5jUYmN2hcjs8GOgN/GS2SVVo1pyodEScggZl6uSpGcyXSl7a6Jb+ N2QPMAjxn8dbxr5Q/E1JO9BFsMgkdmIMiZQJlHUYKAfpr/vOMvedUtjnw/QNwr8ubXTBjRp2wS+E eIIHZeiisQcMzNDKs8PhRXGtU6BMkRAdfM/ZBtfPXjm9YmcdOC8vxtoYI45gnmDu4sxzz+oyRaQv X+5wlAsQwqBMbTQA6GvWBisFPJBS3h9QZ5GxRz6onPX2uWa7yHUxrgCx9TV7LfcqJ335NuAFIJCc owJAjkkt7TzSKLW4UASyaHmC3z/UPO6F4gNltovRUYf4IYRW77UsWKuw9C/Mt9EVfD2kV5I6GgCk JfByZYcXlc2eMDeASyTqFNc6Jyz/4W9fNwAdXVqakGhd1eJe9G6Z7+q2ok8lm2+AEEAyEQcgUccF u6abbEKEvMpg/rsE9uDG8wvXllW3eC4tUziaZiSqhuXoJRNF8WGxCcE0ECCZSAEA13qxzAeAEGpz isH3tpTN3VTzePVey1enrZeNdIK3/PZhRhaGX/bi65IhhNpdVH7LDH4EJBNxADQ8CazQwFBEU94g 9dEtsL/Oyrl7dFLwpaIO40XThxojl4/kFqg0d2moORydeEAHxxMhYgBId81euyvnoYlpl5W+xEID M3RDc3RgOCr+VB6RBa/CKMUDgq1BgjHPYUUSFziCIYyOfX/gvs0rM7N/OynNI3Yg/Q5VHDzPtw8z ocjc7lJxOFjcoXkBugyAS6By9YS/1eEBICUuQAgFnHEQtoUbHw/faA64b3PpUgWRZiEVh6W5X+Cl kGigy5aT3U11KiLNHHbIeC3ntQq6Pls4yo8rMCTHcfVWodUhWp2i1UndInN66E/7xi+fkX0RDzOS 58HLZQEsNCBdO7yX/mCZTcFr7zDHBB0nfwD4dfm18ggC0OoQk+N4P6sAY/k7IYRi7h6dtG5hb2ky dje1u6nVJTbZxIJUDfIv+CP3NLz4ee1Xp9s//U0fyhjxNwslW+hgmU1xFoL5Mn/YWx1iNCiovk2Q n7nghdc/GpL0eydr1i7IA2MfZhSnIanxfH6KZmSu3qTnArQbjC4Vh9/4sv6xD6u2fd/67L9qgqkG WGjaECOvKAv5fJ/qYCGAZCIOwPkmd7DJn9/FRI8LeWcIlTe5b1pRcrbBpeG9l1LgD2UIfA8B0hcZ U3H49V31v3mvAjxxT26t3n6yLSDS4mWhDO3wXnrFbaHeKergwwFIJuIAlNQ7gzWif7o2NFcEFJL5 6rR1zF+KNx9pBqJnPodzcB4nhIL/8lktXDugjEFi5Lx3SqtaPAHJkKD4tw83KmgLgakD88WXlEyk ADhZ45SfwuFF/3StisOhZeiBeja2CzPfPPfIB5Xgewj2fcKaaLaL968r/+M/qqQSDhBtr7cKc1aV eutmsUBbSCkWAntBxeF+Zm2wEIqqowJAUbUDZAQzgkfITVbnJatDVjRAjmC0YmfdDS+fLq51Bkdu IQn5d+9XrNzTCGBLIoXE269PW//wYaV8M4CH7GfWjsxThoWwl380ubLJQixPoKyoxhENAE7XueS+ T4iW8ASPyosL+TQoKThP8N6z7WNfKN54oCnAdmS+vzsM+4Cdunx73T+OtsjB87KQQicymN2oPD38 hDco6/OPltS5ogGAw0NPVNr94kEIIYRuHJAQvkcawmEtdnHOqtIXP68NjsOYE/iLWfSwRBa+W3a2 wcX5gqY+WyhRkROZd6b9/WYKcjheaQ8hIh9iWsqeM+3yJ4BJTuoXr+FxOIna8nAYR/BjH1Z9fdoa wEUmPX/pXP4Wuzh7ZalbYLAZQPpmnzTthEJDmCwEa13D40n94+VrnclkEiUAIDGGk2WHUYZyk9Vj 8w1SdaRw760zhjG6Z3VZk02QX01JjuMuDR5P8MEy2+82VUg3rQHR3DC2KEnPMEbjCgy9ktTyQzjI AWQSJQBOVDnONbqwrLSgN0d1lEmpEw/YNueb3PevK5cHAJLgCHq5i11/+7ph/f4mnmC3yFQcfuaT mjV7Fbgzwpg3D1d6HkiSPNvgCiEhJUQAeILdAvv0363y5wAtmDHMlKjjwmchuTp/eLTljS/reYJF ipAv6sQuF5AhGD2wobyo2qHh8R8+rHr64+owK1EC/xj13IzhJvnqBwl8+n0rJN9FAwCYxfuH/HJU 4flS4/mZI0zhXBfp8ML0ks2VRyvsGh5T5k0JYJfPs8M2F124tvzhTRUvfF4bfo0ZmNHMEaYUAy/X MJDApkPNIRsgIcQDGMbou3O2kzXOYLX67aQ0OCIh5S5MuwQ2Z1Wpw0MJRiv3NHaGykFGB8psr35R r8htNSjt+NtJacHu2KJqx3fnbBhHKx4guelX7230YyGCRcquzdJNG5JIlfO9wFedqnEu2Vz59MfV r+ys6ySZgAnEKVFfCRbQ9KHGazJ18pAczH3NPgtkBIemdSQ0ZkAIvbvP0mIXA36YMfTU1EyoCY8U zdP6368anvmkpktUDu48Rfw/HMFPTc0ImCmHcZtTXPddU8iXgkIEQLqQtG6/Be7sya6FssHZuvlj k6miDkhY7NEs6Rxw13XhdcnXZumorLIyRD3XfdcEFw5C1jcScnYGxui1L+qdHkpkiwBs9uemZ5r0 fpWbFMEg+tKHy9lJcfyzt2VS2YVsSHhxeugrO+pCvm8TFgBgp59tcK3ZZyEY+fu/WHqCavkd2TQC aTlRBwBThpbPyDInqOT6BJbumn2Wc42uMAsCkHBSlDBGz/2rptUhyhcB7MaLrkuZck1ih7HcK2XA ddepgxMXXpci33tB/Vsd4nP/qglT/cMCABS8qsXz52218kUgVZJYdW+uOUElKkpE0SQfkbH0BNXK ebkB1SBgRn/eVhscAop2wSbYlF7bVQ/F3mVXyBFlLD1BtWFRb2/roysKA+wrHLphUW9zgl9RIDif F1U7Xv2iTpETDwm/BrDTQx/YUB6Qigye5J/1j3/1zhyxo4tzPXlwvly8Sf3jBX/ygfn9x8bzLoEh JVKvwy3aByZpmcUdr+Wu72MILjM4Nt9bc1bFXRmls+DK9ZLJ5uAbr5Dq8vKOupV7GpXKNVKmci5H 8Jc/WKcOTswyqsWgMoO3DEqsafUcLLNHovay4tL3iGzx+JTXZ/UKuO0N5HPkvP3uVaUIKeZuIUp5 bNwCm/12aatDlLvvpeTON+fmLu6omHqP4n2eeKX/5txc0V/6cAhodfhFe3pQ7WgI1Ta0C8W1ztmj kkRZ/Wuoxc8Qum2IEbgIcm9ZD7N5MEIiQ0snm1+f1avD6sscwbNXln57tp1TdB0rVrwb+l+crHHa 3IE1Z31ZpOzmQYnJBn5bURt8uIfQEU+wyBDG6LVZOcumZAT3lgHqX7ql8p1vLYoXfVeyfD2kNew5 056o464rMMizyaWeFGPzDeMKDLuKra0OMUItEUIoLpRlVG15oGD2qKTgKh+QBrliZ91TH9dEogik wg0coAb5tqK2TKN6dF5chz0pCtO0s0YlnW1wnfL1ceyWpcD7KspNG2L86ME+Q3L0Hfb7UHH47d2N D71XwUcm0135HjKwH2w93pJpVI/KixOCelKIlCXquFmjkrJN6gNldqtTRBfJ9olU72JfvxpzgurV O3NenJEdr+VEf4tT6vchFVOPUC+liHRRgm3t4xOtwEUBPSng+M4QGpGrnz06ye6mxyocQKwRJSUg HLj9wRO8eHzqhkW9b+gb780gIh30+1ixs+6h9yoidNk4sgAgjDiCPyvy9qTooE0RRrAUpg5OnDo4 sc1JT9U6pVZfCrpRQeUBddilZo4wrZmfd//4lAQtB1624CZPBOOlWyqf+riGIzhy0o9SJ73bhxlX 3pNn6u5OeikG/o7hpl91opNes128792yD4+2XMGd9AKcugMytGs63UvyH8datnS9l6S8gJR8V9er yfV9DDNHmG4bYkyL5y/bS/JAmW3+mrJTV0EvyYAaqmoePz8ta8nkznZTLbO4d5dYd56yHioPpZtq n1TNqLy4Sf3jJxTGQ+b2j7SbakA/4Yn94lfcmTMku2v9hEsbXWfqXadqnaUX7yecYuCzTKr8FE1h mqZfujYvWSP/hsv2Ez5WYX90c+XV2U84YEvQqsjDN6YtnZzeczpqL99e9+oXV3VH7eCe8rnJ6qWT 0xd0d0/51d9aXtz+4+gpH1xhFSHUz6x9aGLq3aOTvFm3QfzTmWp/nb+MJi21Zru48UDTX3fV/1Dn 9LmDuqfPSjcAICvS7dW4LKNq9uikuT9JHiIrfypQdmld7mSaO/O/Zny80rHxQNOG/ZaqFo+Uy9SN fSa6DYBgGDBG4/sYZgw3/fyaxMI0TbAo5eZm8Hbqi4l6PxawhkrqXZ8VtW450rK7xCo1tWX+BuuP EYBgUkIIqTg8NEc/odBwfR/D0Bx9llGl6notCoGyiib3iSrH7pL2b0raj1faJUO2GwmnhwIgN5Mw 8ktmjtOQglTNwAzdNZnawjRtTpLanMAn6ji9mqg5DNumW2R2N211iHVtQkWTu6TeearWWVTtLKl3 2lxUfiRkynVKvwoB+HEOEhNBDIAYALERAyAGQGzEAIgBEBsxAGIAxEYMgBgAsREDIAZAbMQAiAEQ GzEArvrx/75Pqb8SMe6KAAAAAElFTkSuQmCC LKM_EOF_DIR_1 base64 -d > "$DIR/appicons/docker.png" <<'LKM_EOF_DIR_2' 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 LKM_EOF_DIR_2 base64 -d > "$DIR/appicons/fail2ban.png" <<'LKM_EOF_DIR_3' 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 LKM_EOF_DIR_3 base64 -d > "$DIR/appicons/git.png" <<'LKM_EOF_DIR_4' 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 SUVORK5CYII= LKM_EOF_DIR_4 base64 -d > "$DIR/appicons/jellyfin.png" <<'LKM_EOF_DIR_5' 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 iA0XAAAAAElFTkSuQmCC LKM_EOF_DIR_5 base64 -d > "$DIR/appicons/mariadb.png" <<'LKM_EOF_DIR_6' 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 lWVD+qZ/4wAAAABJRU5ErkJggg== LKM_EOF_DIR_6 base64 -d > "$DIR/appicons/memcached.png" <<'LKM_EOF_DIR_7' iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAbCElEQVR42u19aYxk13XeOfctVdXd 08v0NMnp2bmIpmYjKW4SJTmMogBKIDG0TBleYP0QEuVHbBi2/tihZAOGwNhBAsM/IjtAEiehQCGA 7FgIFMAOZUkmJZGyQWkkzgyXmSFHnJmeXqur3nqXc/LjVndtr169qpl5NpK6GLK7q7r63fedc89+ zkNmhsn6u1tiAsGEABMCTNaEABMCTNaEABMCTNaEABMCTNaEABMCTNaEABMCTNaEABMCTNaEABMC TNatXe54HyNmgfi/L1389voaJwkUTatx5/+EI/ZOzTx59Nh79u41zA5iOfdsN//a+tr/vPTWVhAJ AATu3WTWxju/eMKRrvvw3PynTp6+kc2PSQBAAIBrYagRrzSbhrnQBrrpxMyXw+jMxto/v/e+Dx8+ Ug4NLPovX73ylbfeRORYya00yc3Lcj9FGNgVTl2mTnP7UydPAzOUTAAEBICNOE7S5Oz6WmqMGLgD HsRVAIDALMSvb248q9RH77rbEDniFkpFS+O/Xbn2/MU3hdEbUfTa2mogJaKFGQewS/st3LkfgUjG 7J1bAAC8Ab4ZhwB2O4nWjTSJ0xQRK44zhINwwBsoEEAp/Zvf+87S1NT9+5cth94i3ncQL2xtPnf+ rEBcD8MfrK4YQ74j+k633TMPFEXAQgjDcKW+rYzxHIeZxyPDWOzGDADbaRoaE6WpMsburPsfMzAz M2e/RcAMwMxEjFprxN/41jfWwxAR6RYUajCzQNxK4v/82o9RiHoY/HB1hYgcgb3bY7K77nmdAMje FDAzkCEm2kyijSgcfMJvDQHsxbbSxK1UYq0yJCTsoJ71FnfqMgYAIABO002Bn/vGXzKRxetmog8A CMT8X378o9TBJI7OrK0qIoHYo5QGWRPt++n4igASYaXZuJENjy9wN5NEuG6q9I1A334LgeP0h0n8 77/zkrjZh4CZEfBPXz9/1SgVhj9eXw+ldDvRz4Weu6FvYyeE8Lwr29t/N37AehwzYqw19nHJSNB3 gECQJP/90oVvXbzgCHGzaGCVyo9Wr7+4fp3C8K2trdUo9ITgrt2NBn3rJUeA61yub5VOAERLAG10 ShoRdrc6HvTtl4xBgM9/58W1ILgpysDqxnqSfPn8WZd4JQwvbddb6HfvLkuGwmCWsjAgAl7eKp0A 9jNbSSSlTLXBDHNhNOh3P0HMrGTTdb7wVy/gzdAEVlI/99oZ7blBFJ7bWBdtOyIHeh7I9Z13QgQM K43GjViiYjwNrIkCqRIpDRFmGUmjQt/+BCKn6feCxnM/eNURwhDdCPoC8RtvX7qkpA7DsxsbiVIC MFPm5ELfZ+bwjmYHuBY0rJQbj13EeDZooGSgVJJ2EoC7dNno0LdfNga0/oNX/+at9fWxlYHl/atB 82vvXMQ0fbteXwsDD5GzGXuI+ZD1EzMZZtoMoyBNdyVzSSegkUoDEClJQ7geRoJ+9xCQVrpS+fxf vWDGskpbHgbzc6+dcV13Kwgu1LfcXcV7o9B33VSg0vUwGNsSHZMA2zJ1KxVpzGCuz2V8yI+9MDOA lOe0/ONXXh7DKrVu19ffenOVWYbh2Y11w4w5pv1o0LdvVwjBrrdmCVCODrC3sZ2mwnGk1iNAz/mM zz2GHxnDUv7xuR+fvb4ykjKwEvlyY/svr1zmOL5Y39pKkl2rv30ZHmxfDoO+Ew3huqtB0B9qvLV+ wFaSMEJiNMJIXM9FAna8I4jAGPC9f/3NF5TRWEzLcSvoRs+9dsZznY0wfHt7e9fqH4HroYAOAwAh QOC1G/DFxiTAdpoaIqmpwwbl0cV9B9f3ciQDMAGDUpfI/IeXv1tQEFnh87/efGMTMYnicxvr1OlV FeH6XjEz+KbsfhCvNRulEyBJldKSDI4JfTbXZ/CeMZzK/3Tu7JlrV4cKIit8LtXrL1x9l+P44tbW dpo6u5ZPQa7nYVy/c1O8I3auWxFUjhVkLxIqJZUyRNkXHQ59f3Qrw9OxAWswBiv+57/5gtR5gsi+ qoi+fPaM7zjXg+bbjborkPO8qjG5nju8YQZYD5oAIEoiAKI0JtBJqiQB3yToYTAcQMCg5DtAf/jd F3MEkRU+X3vjfF2IOIpe39wAG9Lnm8z13KvaeCuO+q2sWyiCYq0TbVKtutzgG4J+iOHBxJyk/+2N 83/z7k8cIahPEFnh8+bmxrdXrlIUXdjabCSpkx0jGR16HmRCMJMB5nqchDItTwdESmnmRCq+9dC3 YSNC3//CN1+IpYTuOL79PtX6uXM/coRYaQbvNBp+l9vFOQ7teNB3Cs5Apo04KY8AgZKO70uiTm10 i6Dv4HEAqa4K/HcvfVsgdh4CK3z+9Py5QDhJFL2xuY63kus778cGPFiIehyXR4BQKcd1pVII+fZl F/TIIIRAHhn63RNGTCDl/3j74rc7EgZmJ9z/vY01CqM3tzYDKR0UPDrXI7NAu8Ph0O++JoRA16kn UXkEaMoUEA2Z4lxvgkBHAVZ8cETLfB4rWsfGoMDffvFbm1GIiLbKoSnll8+ddZmvBcG7zaYnBAON wPWIwGy2GyZNoVohLAb97lsOoiM2o6jUE8AAkqgvCJ4lcBA5TcFoh+jj+w/MeRV0PSQe7mRm6RVi Zqk2Xfytb/yf3Wv/1zM/VK4TxvHrm2vYkxsaEEHrVaVBCELMo/Pxg4cFCtFlUQ7PzIAQm1FYog6Q KTEpw9glDnmArCfQCqvVz334id/9Bx/5t098RKAAIbCIk5l1uAiB4/S7jfoffP97DuJXz5+7KGMT huc21mKl2qVdhU4YAwCnKRA5jvOHT/3sF5/46K889ChXKtgu4RiWmUFEwM2wRAKE2mhDmgkHcX1n zoKYAOYAnj5xUhM9tnzgV08/UNszw5k+bU6MugMOIoI4/o8/fPVffP1rf331XUiSt+qb18OgK9Nb 6IQBM3OaQrX6mfc9/NDBQ4boF0+cPjk7B46DzEV8CGuQbZWphGMpNRlNesAB7eIOIEbHPXn78pTn IwAx//LJU6f3zIHnCcSCXN/vzRoih+il1ZUL66uX61sXbbifR3Bo2cb7pGQUd1T8f/nY44YIEac9 7wMHDh9cWDTEWMR9YwLgelymDtBKKUU7+xtqXCLiPUtLvGNduEJ8+sSpA4uLylZUDOX6AWEcRhBK XahvnV1fE4iFub7DcCbiJAXf+1fv/1DN83aN2qXa9O2zs9Aukhzkz7UvtB2VdQKIOdVaaUNDYwnt e6bD8/NWY9vc6WPLB07N7KlUqwOBGloS0pH1ddrVzaOY9oigFDvi2NT0J46f2C2JRMR901PTnu8L QUPiFjtJbIamLIsAqTGpIal1TzZ4oM1Hhg0tTk3t1HEAAzhC/Mx77ju0uKiNxrGgL1hAkudVMXGa gut+9rEPuKJV3Gl/Ycb3XQG+6A785SVdOZBKj1U/MAYBtDRGaQ3MRbKmDMBEc9Xq7kv2Ph/av/xT telqrbYbxR0D+vEdWgRQmlEcqkx97N738k4s025lyvOQyHddtiG23Hw3EQFDJNMwTcsggDTGMEuj uWAYhwiIaq63e3u24McV4mN33X1wcZ/SGvL4mG9JLIGZpQTP++WHHvZdl7p9mqrrArHnOPmlBm3t xJxInWhdBgEipaTR2pi2Ch5e70Zed/26rYp9ZPnAYd/zfY+ZyoPe8oBUbGivwCdPnGJm0d2U4KJg IrdtBPGAXbSZMNV6vIDoODoAhVDaAA83PHZKv6m/9YWZpz3/Hx2588C+JcP9/QO3MIIGAKwNVCpP n3pwT6VCHaH8tqVALAT2hDS6oe/SxZrLIoA0RjiOMQYLhnFs1riPADa39aFDR/a7fjd5bk3cuM39 CFqTUhWjf+7+B7IbKxABWAyDvss4FCKSqgwChEqBEHpAersngtbyVDCjaMmaQ4u12vv3L9++uGha nXKjhnEARgpe2qU1VryP3vOe/bNznJVKZG6rhKHQA7AQiEI00rgkKwgRskpCB2dmcvOITxw5ur82 BZ2x1YJhHB50Pc6LoJEhKUHrX3rfI9zHF/YF4papxlaMDu4eaNluCIgYJGWJIEA0TICFUsFWpuqs 2LVARMQ7F/Yen5ubn51j5kJhnDyuB8i3phBQaXDd07cvn95/gAEy+zI1ESCyDRhSXv1jW9UhhlKW QYBEa9vY1W68y48lIACitH1knBFeRoAnjtx5cGHBGEIeMZYAAy3DAb11TFKCEL/04EM51ZyKjBCC M095ZiEHAKAoSQlHShKzLQLgoe0lDIgCcEdBZaliALj/9juWHbdWrRJTvqblUcV9T9zYGGJYctyP 3HMvD27HTLUGgURcCPodpR2kSSk6gIiZiWhwjwN3FkIhAAoRKwlZvaoIQERV1/3pQ0cO7FukzH7W DiMHxoN+1/2SCn3vn508XfM8yrJ/7M4VEaLYsY05D/oO4RalpYggbYiIiTOOZ3YswXFAYKzUwCOP CADvP3h4yfWc3lvjcYycQVWeRhsphdKfPHX/oJ4W24CeKoUCDVOfBMv2yewfikwpZqgyimwyBXF4 BI3Z3mpzcJzEesV3zMzcv7i0tHdvS/LeROjtn0FAQ+h7Hzp69MjCXkOUU8gWaQUAhgwOvFb3z4gI mJTjB6SGDBF3tQQMMTwAoZkrH+1f+/CRY8t7ZpluLvTtcI1JUwD4ufvfN7TKOtGagVsnIPtaGZIr LscKUkTMxO1QSIHKS8TtJI8AVhq8d9/SQc+bnp6imwk976pfRtxfm3r82F1D6zgjrYhYG0bAnCa9 7iIQissRQZrIEBMUqBVoD+TArdyqDZuq9B3ng4ePLi8udtdZ3hj0u/au0uC5T5047TsODZvrEKaS DBHTwPLtrn3xbmleSUrYMDFx0c4eZgBoVe4Nvm2LyCPLB/cJx22VQN8c6BkAjDFSulI9dfJ0kZbS WCpNRhuDmHettuZDAIZUqZJSkmS4r6YhP4LGW3EE2SNTOkJDzLdPz5zed9ttCwstK+uGobc2ABgj PPfxI3cenJvPn8Zi34q10lpnWHr90HfFCMo5AWSIiYu3EiICQz2JeCchnN9d9KEjR++YnYViJSHD siUAzEBEScoATz/wYGt8S64toIlClUqtKCsfmQk9EwGwLEcEGQbrImLBVkIiAG4kMhl2QndU8W3L jjtdm+oovx0XemZAREMEcEe19sFjdw5RvzvsnxijdpPeudB3KmKlTSkiiAyz4eIFtggAEMgkkEO6 ma0qrjjOBw4dXl7ctxMRHiEzw51uye6LSqHvfeL4qarrDVG/zLb7QRotleLM1Neg0hlm4lKS8opN +2wWiBvbeFEipfXFinQ6Pnrg8F4hxMAqz2yuz2ZTIiMlKv3kiVPD1S8iAERSAmIqNTEzwuBIdOe1 0BropbQoMbYEaeFCKCEEe56tH+bcfmYrHw7smT2+d3Hf/ALv2qODMzOcAUeHUFNaeO5Dy8t3Le4b OgzNMkczTR3PT0kz5uYAuPeUm3IIsKsDiqYJrSHiuraff2g/uT1ejx8+un9+nnp7jLkQ13dqR5Wy wE+efrDI4bN/ZTuNheNoIoS+QnXmwYN5oCQRxG3+L5yhdQQ6YrXZLLQhRAA4ddsdt4Goup7V4QM9 vcHQAzJoTcSzKP7h3ffaAVdFRkHWk4QRU2N6CvzykmI7dY7lTEuhgaw0KIKGCIjXgyYUdjVmfP/h 5YP7W7niEZLjHYggaIOe/4/vfe9stUqFuxi3k5SZVcsLG3LCulI95YwqoMzt5AYv7Z2sBo2Rztmj Bw8tVWvYH48fAv2O82yYpGKtnzpxeqRJGo0k0mQUmdbEqOHQ77T8cEnDOrBPCAwLXhIBw2ozLDhZ ykqhuxf2HqlW52dmaFToGQARyTDi3fML9x88OCj3m3nd7VQqpZRSI+QjWz+VogO6Bk4VbSVEAF4L mly4ndwW8D526Oj+vYst6VEQ+k7z33M+ceKUgxl9xYNMIEUmSNM0lZp7QqHDk2IlnQCykHZkaIfC YQNHW3Fo6wa4WD8+ADy0fGAvoAcI2QgOjtYZY6Rylfmn7z1RdKBbywlQoZKpksaY4tAPnDF9S0QQ mdwMbRYcCMBUTxIblC7CKruxuRP79i3tXegbBJAbKEVArYXvPnr4yMH5haKzkG3UNk0UmURKBsYh +ci+EppyTgALwZgZHxiYmbE3w44z0mgv+0vvP3Rs/+wc8G4VTIEYNTMpyYA/c+qB/OhbxiSwNPEq lURrarlXnO96935XzrQULFQI1cU7wnHQda+PMlfHyo3jS7ctOU6tWiU2UKz2BIwhhgXH+em77kHE 4lrH9to5rqtIYzGu7/wdgVCSI7ZT2Fl0viYwADoonKujTJbCHYfgwdsPLC/aihUcHqMGAEPouR95 z0/N2MrnYgTAFgEiQIi12pmOUITrWy+UND1dCNF1NmF4wrplvQr4yfbWqLNxmPnRQ4f2VauCe2TX AAuAmGRCWj/ZMv9HkwsbUWSI7DTaPK7vfqaG/VUHRSkE2JWNoyRMkAkArta3Rwz8MSLevbB4qDo1 Oz3dWQ2ZbRAhgNGE4tjcwoMHDzNzcVB2T4DSWiqFffKu77t+f7+kuaGIRaHvOKyIzHC1sQ0AxZ+R YcdHu0I8euDwwcV9wCyABYIAFAAC7DcoAIVAIVAgojHCdT9+/ORIE19tl4AhqqdJKqUmEgjFoe+w Y289ARxARFG0GqcjacfA61EQ5ZTIDV4PHTjoKqmSWEtlUmmUNEoZpY1SRkmtpVZKa6WV0swk5cfu Oz4aSyICQKhkJGUiU72T3S0IvQ1HjKeE3TFGliECcv6Qx6x3mOtRvBEGU/MLnJug762bY94/s+dY bTpaXLpjembWr87WqtO+X3Fde0RSrYM0aSRJM42vx/Gx2fm7Fpd4pCFuzIDYSNNUyyhNDIAL/Q9O GvxZBgDwHLcMAjjYd2PDsyVgG+G0I1YajUPzCwMfKzPYHv03/+TJMYYLjjRoejOO3Fotrm+RfYF5 CPTdOt4VJVlB9gyMmBwHQCGE713ZrgOM6bMwsyEa9E8T3eDzBtbCQLiu1BoZIT/nkRGjRkc4ZZwA T4juWTO52ZIeOSucy1ubYwOEiLf0QWPrYcgMUd4ksBzLlr2x9ja6CAJrhowCfYfyeKe+cYMP3rqF BIhCbXRqNA6ahjqAKCiwPB0gAIUA0T0KPh/6XUsIgK9sbf89JIDdTz2OpZSpUthraef4c22H1Hed MnSA53RFV4onqux/1xp1aQze7AdV3ciyGjjWajtJ4jTV7Rbo4vlIAIaq65VBANdxxI4fMFKiig0B 80YQrYeFn3zG+S2iN+mzzDYMF2kVpelO6HsE6O1XvxwCeChseQEPrxXgnmOKAMrBa9v14b4YMxPZ bD4gAhEXrrphZuj4LLfa2YbEQTei0K1W4t15zEOh7+q9ZECuem4ZBKi4busIjMIgrYu5rvDdy/XN YXUXBIgoBKeprtdZaxAChSjIzogIQrDWul6nNEUhWmTIXath4LhOqlTOI7lyZk8AQ9X3y1DCvusK FAiZrhRDrk+GiOA4b29u5KOPQgTnz9f//M+b586pKPJqtdnTp/c+/XTt0CH7bv5nk6tXN7761eaZ M7LRcH1/+r77Fp96aua++/I/uxoGzBDaxyzB4McLc+bwfQaAadcvSQTZYMRICevOZrZLG+sDY1fM KMT6X/zFu888I3/wA04SwUyNRvzCCxc++9ntV17JOwfMKETj1Vff+dzn1EsvcbMpADiO5csvv/1r v7b29a/nn6G1INBaJ1J2mEA5XJ/hKExXK+WIIEcIbD09rzj0OzVdwPyTzc3MC1vJE77++vUvfUlX KtHUFE1P88yMqVaDSgW0vvjMM3J1dTfL31/TIFdXLz/7LBMF1SpNT8PMjJmaCqtV7bpXfv/3g7Nn M2WRHYm/EYWJTCWZHRO5MPStBwnytF8KAaY8XyDuPIe0KPQtOUnEwFca2800QcyufV15/vlatQrV qlOtunv2uFNTolpFx5GVyrRSK1/5ip00DH1V84B4/c/+bJpZ+b6oVJyZGXdmxqlWHddl36963tU/ +ZNBGriZpttJEiWJ0hoHGDmDnnVlh8EA87Tvl6SEsVXnxMW7OG0sl4CZeVvG15vNXkOIGYUwYRi+ 8UYMAIjC951aTfg+Og4KwUQJwPb3v8/M/aLcipfg1VdTK+hc16lWRaWCjmOnTyaIwZkzJgwzBdFm HBrkKIkNUEu6Zjy+drB7w8RMeyrVkkQQMjtCMEPxzMyuueoIAb7/zuYG9HZ0MwCYMKQwJGOAiJSi NCWl2Bj7gGUyRjebLROzm3iAyMboICBjwBjW2qQppSkYY81QMsbEsQl6yzJsSfP1oOlVq5FtyoB+ 6AeyVOdzNPZUSyHAlOsjsYM9fJTfStgZS3LQ9S5srPXELazkdfbswUoFtTZpaqJINxoURZSmLCUa I5jd+Xl0HIt4z/hzdF1nZgaNYa0pjk2zaYLAJAlLSUqhMWJmxp2b6wmEWH6/1mwiYqwVAnYUnOVD 3znTnYHKIkDNc5nIyalX6uJ6zgzJvbW2An0ZVSZyarU9x49XlOIkoSAwQaCDwEQRJYmSssK88Pjj 2UqYCADmHn7YT1OdJCaKTBCYIDBhSHFMSlWNmXvgAVGttvy7HsXTaGijo6xUcNZMJM6ITRszU44I qjouaON5LiIjWkB3lnU9ERCh+43dX7ATh+Hi+qZtG+hNoTDv//Snwzh2gkDHsQ4CE4YminSSzEgZ z8/f8alPwWAdcPsnPxlWq7Uw1HGswlCHoQ5DkyReFMWIBz7zGegrUrc/rsVNo02qlBAI2Lf31g1l 31Pr7AoxU6mU4YjNVCoMHEWhiRJ0nL7HgXF+yMUAAOLbcr2ZpnsqFYaOGlghgKh29OiR3/mdK88+ W2k2JbNhFsbUAMT+/fd88Yvu3Fyv/OmQQu78/F2/93uXv/CF2spKap/sQ+QDwMzM8jPP1I4dAyLo Jh4ibifJRhhdXF8Nmk3HEcAjPfmydWu+646nhEcmwLTvr9c3w0Y4JxzuaMqh/NF63Wa1RlhpbO9Z uq0XTCGYaOGDH6x+6Uvrzz+//cor3GhUlpbmH3986Rd+wV9YyEa/Pamdpu+9964/+qP155/fevHF dG3Nn52de+SRfT//87XDh/s9YevMb8XRW9evvnN9ZQYFdPZmt1UU5matmRz3tqnp6bFOAP79CQv/ /7nEBIIJASYEmKwJASYEmKwJASYEmKwJASYEmKwJASYEmKwJASYEmKwJASYEmKwJASYEmKwJAf6f Xv8XkvL/PHKUxysAAAAASUVORK5CYII= LKM_EOF_DIR_7 base64 -d > "$DIR/appicons/node.png" <<'LKM_EOF_DIR_8' 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 LKM_EOF_DIR_8 base64 -d > "$DIR/appicons/ollama.png" <<'LKM_EOF_DIR_9' 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 LKM_EOF_DIR_9 base64 -d > "$DIR/appicons/openwebui.png" <<'LKM_EOF_DIR_10' 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 SUVORK5CYII= LKM_EOF_DIR_10 base64 -d > "$DIR/appicons/phpmyadmin.png" <<'LKM_EOF_DIR_11' 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 SUVORK5CYII= LKM_EOF_DIR_11 base64 -d > "$DIR/appicons/portainer.png" <<'LKM_EOF_DIR_12' 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 DaOeAAAAAElFTkSuQmCC LKM_EOF_DIR_12 base64 -d > "$DIR/appicons/postgresql.png" <<'LKM_EOF_DIR_13' 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 LKM_EOF_DIR_13 base64 -d > "$DIR/appicons/redis.png" <<'LKM_EOF_DIR_14' iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAciklEQVR42u19a5Bc1ZFmZp5z69nv ru7WWyCBBAgD5mVYzFsgYQmQF4wYhgjbM+PZ8dqzjvAPBzvhDXvM7MTGRjjGy8ZujGPGMWHPxJjl aYNAyCBY2UgMiwGjF5LRG0mtrq7qdz3vOZn749atvt1V3V3VqFvCUScIImj6cet8mV9mfpnnXBQR aKxzt6ixBQ0AGgA0VgOABgCN1QCgAUBjNQBoANBYDQAaADRWA4AGAI3VAKABQGM1AGgA0FgNABoA NFYDgAYAjdUA4A9y6U/BM4oIMwCgUt4X8seODr+1a+TttzMfHSymUta4OhaLLlnafOVV7bfcGr/6 WhWJlH8QiQDxvP1weF7PBTEDAFDJTYu9p4d+vWPg9e2ju38XKhYFwBWxIgJAAJpQI7os4eXL2269 veOudfE1l5cQZAYQQMLzD4nzEgDP5Km0XzYzNrxzZ/qVl4be2hU3rsuSZ7ZIgAAi6D0/IiCiAIOE EaJIWebY5VckNmxsv2Ot09VV+sXWAiISNQCovu0gDDC+QZm9e9KvvDywfTv1nwHArDHgOAjA1sJU j00EiCjCzDEijZiPx9pvvjWx4b6Wz91Q+s3MAoCI5wM1nRcACDOIlCm+mEwOvbE99dKWzN7dMaIc s4sILBgO2eFhdl3d1IRai0iJowA8ohfLNpshx8FQCESECAUUSIzIFXFWruxcv6Fj3frw4iVlh0BE OKcOcU4BECkZMhEAiDEj77yd3vLi4Js7IpmsK5JlJiLxvKNQiCxfvuw/fbt/ywvDb73pDg5RJKKi UUASaziX42JRxeMtn73aHRzIHT3qYQAAgoiAghABCCNmHafjpps7N97XcuONFI6MR5pz5BDnCABm CZh8/vjxwVdfSW97xT1y2EHMMDOgIKBv4F4K5A4OrP7h/+je9O9zRw6ntm1NbX05c/CAuK5uaYlf dln7Lbd13Hp7ZNnyDx7clP/4OIbCk2hKiAgAmOOKWICWLu24+56OdeujKy86hw4xvwBMzAttJjO8 683US1tG3toVtaYgnAdEAQE/tAYflEiM0W3tVz33S6czAQDiuoM7f5M7erT98zfHLl7lfVvy+WcP fvtbTmenWFv9ETyHAAiDRIgyiG3X35C4977Wm29R8aZxv5wvh5gnACYZV/bAh+mXtwy89homewEg axmIGCaYfHDrAZFdVwoFd2jwosf/dsmf/jkbQ6HQBGitFWN2P/xg5uAB1dwsxsD0H40IAYQ5ToQI pqsncdfdnV/YGLvk0qpu+ukEwDN5/zOYoaGhN7antrw48v67cS+6gmdlMtVmiTE2lxVjw909rTfe mLhnY+t11zsdHYEE389nEIt9fQe+9Y3RPbs5n1PRGIXDUqb4KTeg5BAOSJRozNrW667v/MLG9tvv 0K1t49YzZ9XcnAAgfnQtJ5Rjv3s/veWFgf+73RkaNiI5ZvSi6/S7A6BbW5uvujpx17q2W27RzS0T ArgXOQNOBoggMvr+e6mtLw3seCP/8QkkRV5VPOMzIxKiMMeIFKLb1tZx+52dG+9ruvKqOa3mzjIA FQll3+Brr/a/9GLhw/1hoiyzBUQAEa7p4Yjab7mt7fM3hxctUpEoRaO6uUW3tlA0VvoTHllXDfP5 fPq1X5158l9H9+xGpSo9TAAQwPsqTnAJEgANEiXK+9Vc2x13hrq656KaO0sATEoorR15+630Sy8O /HpHJJdzRXIihMgiOGMFJFJ2IADgQoFzOUBAJ+TlnSoW0y2tGApd+J3/3HLtdWItKsXF4vC/veWm U8X+ZOHMmWKyz46OcqHgDgwUk32T/pwnXRCAAGhEASj6esYkh2CRKKKDWGyKt996R+eGe5uvvRZJ ncXk9RMDMDFSFT4+MfCrV9JbXzZHjyjELFsGEgAEQSIQYdflYkGMAS5LCADiUwohaodCIXIcQBRm QPRc3iu7hBmYQcSMjjZ/9rNXPvU8EqHWyeef/fAbf6Ga4mJ88yRCRNQaHSdo8gSAAEWBjMjRghkx 5rJoqNvRGsAVMQA40SEqqrmLEvdsaLvr7siSpWcleZ0tACIgIj7Lcz4/vOvN1JYXhnbujFlTEMkD lBNKVIoLBZvNkuOEunsiy5dHly0PLVzkdHSoWAyVEmttNuum04Xe0/kTx3PHjxWTSTFGxeMUCk1I KH1zI8cp9idXfPd7i//ka2Lt3i//8ci7v1XNzeNBxfMkDznf5BkgwzLEMmzFIqaL5oNMJkq0wNGr Is6FYaeZiAFcEZ6IxORqTuv2mz7f+YWNrTfeRLFYySFm5Q2zAaCUywMAQO7QR6mXtwy8ug16T2Nl QokIAGZ4OLJkSefauztuvzO+Zo3T3jH973fT6bF9ewfe2J5+dVvhTK9uaa2SIyGCtaj1Na/tKPad eX/TRh2Py8SQHjT5gsAIy6CVrL+5IcS0a/ZnsoRYFGGAFqILw86qSGhBSDmILoutiBDlai6mCACk e2HH2rWdG++LrVo9Xx4gAog2lxt6Y3tqyy+H3347jphjLgIg4IQaChGY2S0u/sqfLf6TP3M6Oyfp +1MF3rIdFZPJk//w973/8rOqmQwq5Q6kFz76ZVTq9E//yenoKPuKBEx+jGWQZdRKMYAHA2jEtGv2 ZbIa0dtlA+CKaMAFjloVCV0Ydlo1iYBbsUfix7FSrAZo33Dvkm99O9TRMR8e0PfM0/0//Yn09rJI dpqEkogzmZV//TcLNv+RnzwAYg0JtYgIg5Q6MKf+6SdH/9t/1U1NVWBDLCXpwa8BEEBRYMg3+WDU LSOkEQdcszeT9eJwuUEoPgU1K1oWci4M60WOdgLfU6WaE2lTyr141RX/+tScd8Q++i9/5W59qcBc 9DbSC5XVDNlms02Xf2bB5j/y9qiOkhIRUZUj/OKv/mnfM0/ljh2lSGQyzF78D9gQArgCvYaHWKyA QlD+jtdiaAxAACFEIzBkOGMLeeZ2pUIKeSIXjecgAIyYYR47cADmoSU58vKWoueDzDKtESORzWTE GNTaSxbrj/SMSnMux7msl0RVp8SJvK8Q2hUqhBGWgpToCKcAIFgKIIAFyIsQQJejLgqHLo6E2hWV Y3KVP+6FE5BRK0d15DaY+6b84aIlZjXt7nv7QpFI9sihI3/7eKmd68k1zDNINCLC7LE5Ki3Mh3/w vfzJk+TLyzP7D0Ar4VJNF4fUMk1NhAJg/M3Cat+PAK5IjsVBvDQSuret6YH25uubIs0K8yJ2iq0H AAdBAD4u8r6cmzVmPjzgVMFkLS53VJNCFqjME4LJko439f7zT3NHDi/9+jdbP3fDBCdgnhR+Stm0 n/iDyNCunR//7/85/M7bqrlFmGvjLgRAy1YANEBCYafCrMiglWGWgozH4bL5F0QIoNvRqyLOinCo TZH4eFQFrNSCRnAFel3udTnD4uAs5xvq/imNMGhkxJouTQs0xRUCgJWSfVVas25tHXpr1/D/e7v5 yqvab76l5drroytWhDoTXviqxMxNpbJHDo28887gb3aM7dktIrqltYq2XApAfurt+Y3rsnHZNbqp CbFk+AgQQ4xr7AEYsTLIkmExAojggoQQV0ZCqyLOQkeHCF2RgqdiVZCDZywKgBDyDL0uJw1nWAjB wfFvmPMs6InVqz3DMQIaoV1Rj6YWhQrBClTlSiQSEc5muVikcNjp6Aj19IQS3bqtTcViqLUYY7MZ d3DQ7e8vJPvM4AAXihQOUyzmKcYl0/bTJ2EGa9kYcV2xFkRQaxWL6ba2UHdPZNmyoZ07OZsJVqfB RCgnMGA5LxBDaCZoV6pq8TWJ6BUCAGRYki6nLecZCEEFQoiKRv/j7343H3NBQfpLGU4bblbYralD Uwihkpc89lDxuGpqEhEzOuoODoyZvX48EAD09APUGrVWsbhuaRURYK9ND55pi2uEGQgpFNbNzeFE IrxwUWTZsujyC6PLl4eXLHU6E057e/KXz6e2vKgm1mUlowFAgCjCEk0MoBAYICclqqEptl4jiMCQ laTLA5aNgMLSx5dzOJglPiMJwCjLSMFGXJvQlNAUpyq8VN4OT59BwMnG5ksH7Lp2eAgsA6Jn2k5n ItTTE1myJLr8gsjyCyJLl4UXLnISCfJ1nvLKHTl8+HvfxVCoqmejn2uKn7DCFPsOAaJPupw0PMIi Z3Xrz85kXJkZAaEo8HGRz7jcqqhbY6six+elCYHa15GqRlGx1mlv71x7d3jR4lBXV2Tp0sjSZaGe BbqlZZqhOQ9hADz0ve/aXFY3t0zVkgwiMSXbACBCjiXlSr/hHAsiEABOu/Wz6xPUDYAgYjVt3fs8 nnWkDacNNClOaOpUFCEAAQvVA3Xl3ghzqLu754EHoxeuqJ41lTMlRC+zEmtROyf/8cdDb/7GSSSk /owwyDajLEnDA4aLAgpB4wylHBJRwMXnNgj/r0susYjTd7K8XbYALBAi6FDUramZEBF4ikA94ZGs sWOjFI60XHtdzxcf7Fh7l2f+pW5PRXfQEwczH+7/YPMDqJTXF6vLgwlAIbgCg5aTLg+zsIDGKcu3 SbuPIhHEK775zeu/8Y05B+Cf168vnDiR8+h6Jsw92YsFCKGZsGvqQF0ptAmzzWTE2tiFKxJf2NB1 36ZYcH6kDIMnO1u7+5GHxvbsVvEmYVu7yXvEkmfpN5IynGUBAFXb1nvIxZWykciNjz225qGH5sMD Rk6d2v7YY4PvvlsUcWeshwMZiBUAgAhBQlNCzVBATJiHyOdtNqvb2tpv+nz3A19q//zNqJ1gExiV OvHE3x3/ux/WSD7ltHIS21BAOJrZsBAFINrUdMkDD3z2q19tWbAgqNLPrRrKxux/6qndP/8/qcOH mK3yvKGGh/YyECugEdoUdWlsU6SrBupJ0Y1IjLGZDBDGL13Tfe/9iQ0bwwsWev9/9P33dv/xZopE ZmQemcQ2hkdsSbOj2kwe/Y/gEHY7tKyn++I//4u2O9fqzsQ89QPYWlIKAFKv/er9x39wMjUwaBhF anfbcYdAiBN2aepUGCEUGU8Qp+IlELG5HOfzoZ6ejtvvXPDQw9EVK/c8+nD2o9+rWGwqSpxYxEq/ kf462SYY1SKEXRq7NUUJCTCmKB+OJB7cvOQ/fJ2i0fnwgPT2V/v+4cf5jw6yiOtVKIaH/QqldlMa D9QIHZq6NLUQ0tQV9bgEj8iua8dGVVNzeMHCwulT6DhVzT/INhmWpOG05QLXxzZl/mwi7HaoU1GY /DCGSIjI0qoVX33NZT/+yZwDcOyH/33k5//CAhkRFCnX6GNW+s14jT6NAjyVUys/ULdrCs8YqD1e slZcF6sJpeW00goMWekzdmi2bKMQWhX2aAoSZvCpGDGEmBO55b09cw7Au9d8piildL2qSjVgud/w mF831stLAhAl6NTUNUVFXUWVq2gJeERfFBgw3Gd4lAVkNmwTQujU1K2piRCxypOUYc4xHHUiX37n t3NeiB13eYlChvEeRTC6OgiLHOrRNGyl3/CgZbe27KIsbABAUeBkkc+43KaoW1OrQmcaXgrsflko zjOcdjnpF7HaM5iZdr8sMgJAjLDLoYTGKPmqu0yckwgUbmdcPumyhXnpBxzPm6ym5SGKEE7aFCw1 VAEB2jW2a5VlShlJ1xzxqlXU3KSwW1OnpnAgXyoDL4E+sOeCJ11OGpvnCUVsLWzjxbA2hd0Otfs6 iisVk0L+1rNA2shp1w5b0QjOvPUD+g0PMS/QqltTlCaTtfdvz46iiMtDuMihQcv9Lg+zlAN17Q6R YTlcsKdd7tIlk6TAKJc32cUCWZF+l1OGva13ajP5INt0OeMV+1RbH1To+gyPWIFP1g/Qs9BMvEB0 omj7XOtRZFwhTiTrIC8hQJemhKIxln7DAzUH6qBOUBQ5UbS9LrQobFUUQ/SEWFcgyzzMMmqldqF4 EtskHOry2Ya9yZuJbFMOcmWFLuuRG477xPypoeKzhIES1bYq7NaqLUDWlQ6BAE2ELWG1hFXaC9RW pLZMXAK8NGAkbSz5yYwAsJRyzdq33p2CbQCqE72X5gWzWP0JrP6s9QOCmzJgjFdVJTRFqCS6VXUI 7QfqEStJy4NmNoE6SP0KZx48KT+AEQgh9DjUramlBrYxAgOGk0aGznYr5mz2A7xNyYocLdrTrvWq quZqVVUwULdpbNMq51CqHiFMpv3P6Yk+yDYyE9sUBPpc7jc8aksP5pzn/QDPGA2UGtYtflVVKX8G A3UEcVkIFzo0ZDlpeNhKXRV1LUSPCM003jS1UvrTU7FNhqXf5bTh3ES2kWkPd9j5GUtRAEwEU6su ZV4asjJkbdTlTo1V+5STArXXvRljSZkJXe9ZUG2Q6Ds19WhqVagQzLRsYwUGjSQND1l2a2cbIhSJ Iq565JH5ACDa02OTyQIRS5WzjJW8VBD5uCh+n3JCVVXVIbxAvbgcqOuvqMtEv9ChHk1Nvu7tTs02 RYGkxzb1NH49UToMIIhX/OVfXvf1r8+HGpo6ePDV73yncOhQ1lpT86jauJ6lMKGoU08nf5a1BCsw bCVpeDCg9FV1iKAzhRG6NXU75BF9lRHzwJhJlqXfcMpnmxolrNInQgwhLvvc5276weMdy5bObvxS ff/736/rB2KJxCX3349tbenDh/NjY4JItWXBCkuRbdBK2nBeQCNGEFVFKl0OmwAQU5jQ1K5JAeRF it4IC4z/Az66FiBMsNChFWHV5RABWqhC9ORPcgyznCjy8aIdtOIVN1RnXGnXtCKklsZCYRG1aJHT 2jp/J2TG/m3X4Sd+dGzv/l5js4Y/ofw5TZ9ykpSfMjJgOctiygkMggaIE3Zo6lBYKZBUFHReWjnO NlhnN8kTz3s0NSsUAYXYpFSuqWXpY3+VWLd+zgHIHT1y7G/+WnZ/UGB2hd1ZfZ6KPiUmNCYUxQkB q8ufQV7Ki+RYPE4PIUYJwli9kRDEz2cbGR8zqZltvITVa8V06Qnk5gnjIWGrQ9e//e6cA/De+jud VCrjHTRlLmvu9VZVU/QpsUurNoVT9SmDnfQy/7CAQHWhWHlzY14Ra0q5DdXZikGAsiAYqniwUv5K 2EfOxnfem/MsKNPXFyFi5vKnDVZVWYdSvpXNSv6UtDFxhV2KOoPiTEXmGjQbnDQJWVZKBNJG+owd tsL1FLHlVEp7WaxDrVQafg2mUsGez/GCTTp6PtLQfXl7cUhaFRrf3yvkT7XIGW95z0L+zLIctfa0 gQ5VqqgrmyE4bRXiCpxxuS8gN+k6xdEIwQJfZ6zMYoPNgLThky5nrIS0zAcAeeZ9eV7k0AKtwjTZ H6eXP2tUe0oVtYxX1D0ByYwr7B0mjnKedvmMW1Ira5GJJomjJbbxG7+TauZgKEob7nV52I7LovNU CQvAiSInDXdrKkcknngEJSh/LmZVb5+ysqKOE/c4JRYuzZf6+04IApBnSRtJuqWZfT0rtvFKRV2N bcoYF/1xXU8j+oSyqJ71bLorcKLIvS53+gMNQaIou3O5T9mtaeQT9CmzIocL9pRr2xS1Koz6BYQr kGUZtjJiuSBA9bdiPLbpmqJmLo/rlvIol/uN5HyModptE/OhhgYt9IxPFJ7a5VRk9NX6lHUH6vJA 1RmX+9zxuGJ90qP6WzFNyptKKunnlWwz1bjuWVSk9SfXor2nGbYybG3U5YTGhKbYROltqkDdP1H+ nNEhPNRL1a//m52aib7c+O3Q2B0YM5mKbYxAynCfyyMsPJN7zZMcPaP0dqIovf5AQ0s16e2s9Cmr xuEZ2SaM0D21Qhc8HFBg6XUlGehSTB9UvCGlc9YPqMzovaNL8Yk+Pk2gnkWfslbdBgBk8pjJlGzj tx4HTCmi6BrcC5VyREKJxLxMRQBwzVp0hmW0YE+h7ag23jRln9IP1LVXrdNIZq2B4BRs/Fa2HlOm 1BGytRO9UigSA3AWLLj7Rz+aDzFux+OPf/Tzn+eZxbuAaaYfD0pvLYRdDrWrKc7yTVSJ06Z0PKiu gf2yfqC8E5zO+LypTNEMyDOkLPcbztia/xYiIIpIFJEBVtxzz7977LGm7u55UkP3Pvnkb594wg4N 5ZgBUWo7qVGW3qKe9FYRqCu3xlP6+k0pBk6/NcHDswlNCwJEL1PAPGnQoRb9ShAVIjNHiYzI4ptu uvprX1tyww2TbvGZQwC8PzOWTO7+2c8OPPccDw25zK73h2eKQpOGGNsUdQUKH57CIURghKXPHS8g qEJ3887hOAidmhZqiqsqrRgJ1Nhe/7mucV30TpaLRIiKACvWrv3Mo48uvv760p7M9u6yWV3Y5Ld+ sqnUwV/8Yv8zz2SOHxeAAjMR2dp4qXxEoKl0RIDCBJU9snJmAljqlQ9ayYuwBE5II0QJOxR2aor5 QnH1QQeGtOWk4TFbD7MpBSJaJEQETU2r7733ss2bO1etqrykcF6vLPPuQwEAUygce/31/U8/fWLX rghRvh5eGk8QaYL0Nuks3yReyvJ4P8BBiBFGCbUfV3CKE+79Lqfq6vX7RB9BRIDY0qWXPfjg6k2b 4t3dZ2Xrz8alfSLMTP5DJPfs2f/MM4e3bpWxMZfZeJccicDZDtSe/lP+ypR+E6CvgXrOj6B/qW5E KZd54XXXrdm8ecXatToSGb8t9vy5trJ0jbx/lWamv//QSy8d+MUvhg4eJIC8f6XWWQ/UUE2aLmf0 RmDAlorYOlp1RADgsY0NhVauX7/mS19aeM01UD6edbav0D2bF7cGvVKs/Xjnzn1PP31sxw7HmCKz 8cJUnYG63a+oveA5zRhs8ABeypSaAYCgoI60MoJIiJFFi1bff/8lmza1LF06fhK2fDoczvOriyfy 0tCxYweef/73L75Y6O1lgAIzEEk9gdqba+sJKH08cUPLpFT0NXpPka6F6MfZhsiIdF911ZrNm1eu W+fEYp4ZCQDN5f3dc3V59yReKmYyx7Zv3//006feeSeMmBdhRKyZl7weZIywS2O7pigi4YSZlCzL gOW0kay/9bUQvfhsY0KhlWvXXrZ58+LrriuzDc7L22bm/Pp6YRaRshH1ffDBgeee+2jrVqgnUAfz JQchShgj9IRiVyArkuOaM3qfbcKICjHU03Pppk2rv/jF1mXLxq8dnxu2OacvcPDSVr9ayfT3H9q6 9cPnnx86cEDVH6i9smv8xrfaZky8IrbMNl1XXrnmoYdWrlsXamoqnbsPvKoM/lBfYRLM4djak7t2 ffjss0feeMNx3XoDdR0T6kQCoETCREbrC+688/LNmz39YD7Z5nx6h0xFoD74wgu/f+GF/KlTXE9F DbUVsY6IQ6Q6Olbfd99lDz7YtmJFmW3O+cuszuVblMrvofIc381mj77++v5nnz351lteRV1joJ6x iG256KI1Dz988YYNkba2c8g25/V7xIKBOrl3775nnjmybZsMD7veS8RqeNdGcOuBOUrkiiy58cbL H3nkgjvu8H75uWWbT8Wb9MY5IZtKfbRly4fPPjt86BAC5JnBuzx3qkZQQCh2iS5at+6KRx9dcPXV c1fE/sG+S3JSoD7x61/vefLJEzt2hIO85CNRevcLgBKJEBUQV99775Vf+UrikksqX0sJjbepzl7p 27dv35NPHtm2zYyOCoAR8V6IQogKEQGcjo6L1q9f8/DDnRdffH6+t/NT+Drbiop69PTpI9u2Hdux o//gwezwsEKMdXV1XXrphbfddsEdd8S7uj5FW//pAAAarzRvrAYADQAaqwFAA4DGagDQAKCxGgA0 AGisBgANABqrAUADgMZqANAAoLEaADQAaKwGAA0AGqsBQAOAxmoA0ACgsepe/x81uzOMzhmIXAAA AABJRU5ErkJggg== LKM_EOF_DIR_14 base64 -d > "$DIR/appicons/supervisor.png" <<'LKM_EOF_DIR_15' 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 yZEE4L9+/D97oNua1eXYIAAAAABJRU5ErkJggg== LKM_EOF_DIR_15 base64 -d > "$DIR/appicons/tailscale.png" <<'LKM_EOF_DIR_16' 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 AFb7j/YvCp+cJzCYXpEAAAAASUVORK5CYII= LKM_EOF_DIR_16 base64 -d > "$DIR/appicons/tools.png" <<'LKM_EOF_DIR_17' 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 AElFTkSuQmCC LKM_EOF_DIR_17 base64 -d > "$DIR/appicons/uptimekuma.png" <<'LKM_EOF_DIR_18' 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 AkglBfCLKv8LDySUPrTn5aAAAAAASUVORK5CYII= LKM_EOF_DIR_18 base64 -d > "$DIR/appicons/whatsapp.png" <<'LKM_EOF_DIR_19' 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 AAAASUVORK5CYII= LKM_EOF_DIR_19 chmod 755 "$DIR/appicons"; chmod 644 "$DIR/appicons"/* 2>/dev/null || true cat > "$DIR/cf.js" <<'LKM_EOF_CFJS' /* LK Monitor - aba Internet: Cloudflare Tunnel (sites e painel na internet com HTTPS, sem abrir portas) e os acessos ao painel (bloqueio por senha errada). Rota ?cf= (helper lib/cf.php como root). */ (function () { 'use strict'; const U = window.UI; if (!U) return; const { E, qs, toast, dialog, svg } = U; const C0 = typeof CFG !== 'undefined' ? CFG : {}; const TOKEN = C0.token || ''; const S = { d: null, apps: [], err: '', busy: false }; let root, inited = false, timer = null; async function post(route, act, body) { let r; try { r = await fetch('?' + route + '=' + act, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-LKM-Token': TOKEN }, body: JSON.stringify(body || {}) }); } catch (e) { throw new Error('Sem conexão com o painel'); } if (r.status === 401) { location.reload(); throw new Error('Sessão expirada'); } let d; try { d = await r.json(); } catch (e) { throw new Error('Resposta inválida do servidor (' + r.status + ')'); } if (!d.ok) { const er = new Error(d.error || 'Erro desconhecido'); er.data = d; throw er; } return d; } const api = (a, b) => post('cf', a, b); const showErr = (title, e) => dialog({ title, icon: 'info', html: '
' + E(e.message || String(e)) + '
', buttons: [{ label: 'Fechar', value: null }] }); const rw = () => C0.user === 'root'; const fdt = t => { const d = new Date(t * 1000), p = n => String(n).padStart(2, '0'); return p(d.getDate()) + '/' + p(d.getMonth() + 1) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); }; const ok = '' + svg('check') + ''; /* ------------------------------------------------------------ montagem */ function build() { root = qs('#internet'); root.innerHTML = '

Cloudflare Tunnel

sites e painel na internet com HTTPS, sem abrir portas no roteador
' + '' + '

Teste rápido

sem domínio e sem conta · endereço temporário
' + '

Senha do painel

' + '

Login com 2 fatores

código do celular além da senha
' + '

Acessos ao painel

QuandoIPPor ondeResultado
'; root.addEventListener('click', onClick); root.addEventListener('change', e => { if (e.target.id === 'cfDest') fillHost(); }); } async function load() { try { const [d, a, t] = await Promise.all([api('status'), post('apps', 'list').catch(() => null), post('tfa', 'status').catch(() => null)]); S.d = d; S.err = ''; S.tfa = t; S.pw = await post('pw', 'status').catch(() => null); if (a) S.apps = a.apps.filter(x => x.installed && x.port); } catch (e) { S.err = e.message; } render(); } /* ------------------------------------------------------------ desenho */ function step(n, done, title, body, act) { return '
' + (done ? svg('check') : n) + '
' + title + '
' + body + '
' + (act || '') + '
'; } function render() { if (!root) return; const d = S.d; if (!d) { qs('#cfSteps').innerHTML = '
' + svg('info') + '
' + E(S.err || 'Carregando…') + '
'; return; } const dis = rw() ? '' : ' disabled title="Precisa do terminal como root"'; const tun = d.tunnel && d.tunnel.id, on = d.service === 'active' || d.service === 'activating'; let h = ''; h += step(1, d.installed, 'Programa da Cloudflare', d.installed ? 'cloudflared ' + E(d.version || '') + ' instalado.' : 'O cloudflared faz a ponte entre o servidor e a Cloudflare.', d.installed ? '' : ''); h += step(2, d.cert, 'Conta da Cloudflare', d.cert ? 'Conectada' + (d.zone ? ' ao domínio ' + E(d.zone) + '.' : '.') : 'Você precisa de um domínio seu já adicionado na Cloudflare (conta grátis). Na hora de autorizar, escolha esse domínio.', d.cert ? '' : ''); h += step(3, tun && on, 'Túnel do servidor', tun ? 'Túnel ' + E(d.tunnel.name) + ' · ' + (on ? 'ligado' : 'desligado') : 'Criado automaticamente depois de conectar a conta.', tun ? '' : (d.cert ? '' : '')); if (d.via === 'cloudflare') h += '
' + svg('info') + 'Você está acessando o painel pelo túnel. Se desligar o túnel ou remover este endereço, a página cai: entre de novo pela rede de casa.
'; qs('#cfSteps').innerHTML = h; /* publicados */ qs('#cfPubBox').hidden = !tun; if (tun) { const routes = d.routes || []; qs('#cfPubN').textContent = routes.length ? routes.length + (routes.length === 1 ? ' endereço' : ' endereços') : ''; if (!qs('#cfDest')) { let o = ''; if (d.sites.length) o += '' + d.sites.map(s => '').join('') + ''; if (S.apps.length) o += '' + S.apps.map(a => '').join('') + ''; o += ''; qs('#cfForm').innerHTML = '
' + '' + '' + '
'; fillHost(); } qs('#cfRoutes').innerHTML = routes.length ? routes.map(r => 'https://' + E(r.host) + '' + E(r.label) + ' ' + E(r.service) + '' + '|').join('') : 'Nada publicado ainda. Escolha acima o que colocar na internet.'; } /* teste rápido */ const q = d.quick || {}; let qh = ''; if (q.active) { qh = '
●
' + (q.url ? '' + E(q.url) + '' : 'Gerando o endereço…') + '' + E(q.label || '') + ' · desliga sozinho em 6 h
' + (q.url ? '' : '') + '
'; } else if (d.installed) { qh = '
' + '
'; } else qh = '
Instale o programa da Cloudflare (passo 1) para usar o teste rápido.
'; const qk = JSON.stringify([q, d.installed, d.sites.length, S.apps.length, rw()]); if (S.qk !== qk) { S.qk = qk; qs('#cfQuick').innerHTML = qh + '
Cria um endereço aleatório …trycloudflare.com na hora, sem conta. Bom para mostrar algo a alguém; muda toda vez que liga.
'; } /* senha */ const pw = S.pw; qs('#pwSub').textContent = pw ? 'usuário ' + pw.user : ''; qs('#pwBox').innerHTML = !pw ? '
Atualize o painel para trocar a senha por aqui.
' : '
' + (pw.since ? 'Trocada em ' + fdt(pw.since) + '. ' : '') + 'Troque a senha do painel e, se quiser, use a mesma no root do Linux (SSH). ' + 'Ideal: uma frase de 4 palavras ou 20 letras e números.
'; /* 2FA */ const t = S.tfa; qs('#tfaBox').innerHTML = !t ? '
Atualize o LK Monitor para usar o 2FA.
' : t.enabled ? '
' + svg('lock') + '
Ligado desde ' + fdt(t.since).slice(0, 5) + '. Depois da senha, o painel pede o código do app.' + (t.recovery ? 'Restam ' + t.recovery + ' códigos de recuperação.' : 'Acabaram os códigos de recuperação: gere novos.') + '
' + '
' : '
Mesmo que alguém descubra a sua senha, não entra sem o código de 6 dígitos que muda a cada 30 segundos no seu celular. Funciona com Google Authenticator, Microsoft Authenticator, Aegis ou Authy.
' + '
'; /* acessos */ qs('#cfLockInfo').textContent = d.lock ? 'bloqueio depois de ' + d.lock.max + ' senhas erradas, por ' + d.lock.min + ' min' : ''; qs('#cfBlocked').innerHTML = (d.blocked || []).map(b => '
' + svg('lock') + '' + E(b.ip) + ' bloqueado até ' + fdt(b.until).slice(6) + '
').join(''); qs('#cfLogins').innerHTML = (d.logins || []).length ? d.logins.map(l => '' + fdt(l.t) + '' + E(l.ip) + '' + ({ cloudflare: 'internet (Cloudflare)', funnel: 'internet (Funnel)', tailscale: 'Tailscale' }[l.via] || 'rede de casa') + '' + (l.ok ? 'entrou' : 'senha errada') + '').join('') : 'Nenhum acesso registrado ainda.'; } function zoneHost(prefix) { return S.d && S.d.zone ? prefix + '.' + S.d.zone : ''; } function fillHost() { const v = (qs('#cfDest') || {}).value || '', d = S.d, h = qs('#cfHost'); if (!h) return; qs('#cfPortL').hidden = v !== 'port'; if (v === 'panel') h.value = zoneHost('painel'); else if (v.startsWith('site:')) { const s = d.sites.find(x => 'site:' + x.name === v); h.value = s ? s.domain : ''; } else if (v.startsWith('app:')) h.value = zoneHost(v.slice(4)); else h.value = ''; qs('#cfWarn').innerHTML = v === 'panel' ? '
O painel tem terminal como root. Use uma senha forte: depois de ' + (d.lock ? d.lock.max : 5) + ' senhas erradas o IP é bloqueado e você recebe aviso no WhatsApp/Telegram. Para proteção extra, ative o Cloudflare Access (código no seu e-mail antes de abrir o painel) em one.dash.cloudflare.com › Access › Applications.
' : v.startsWith('site:') ? '
Sites Laravel: para os links saírem com https, confie no proxy (TrustProxies) e use APP_URL=https://… no .env.
' : ''; } function destTarget(v) { if (v === 'panel') return { type: 'panel' }; if (v.startsWith('site:')) return { type: 'site', name: v.slice(5) }; if (v.startsWith('app:')) { const a = S.apps.find(x => x.id === v.slice(4)); return a ? { type: 'port', port: a.port, https: !!a.https, label: a.name } : null; } return { type: 'port', port: +(qs('#cfPort') || {}).value || 0 }; } /* ------------------------------------------------------------ senha do painel (e do root) */ async function pwChange() { const canRoot = S.pw && S.pw.root; const res = await dialog({ title: 'Trocar senha', icon: 'lock', html: '' + '' + '' + (canRoot ? '' + '' : '') + '
', buttons: [{ label: 'Cancelar', value: null }, { label: 'Trocar', value: 1, kind: 'pri' }], async validate(ov) { const n1 = qs('#pwN1', ov).value, n2 = qs('#pwN2', ov).value, er = qs('#pwErr', ov); if (n1 !== n2) { er.textContent = 'As duas senhas não são iguais.'; return false; } er.textContent = 'Salvando…'; try { const r = await post('pw', 'change', { cur: qs('#pwCur', ov).value, new: n1, root: !!(qs('#pwRoot', ov) || {}).checked, ssh: !!(qs('#pwSsh', ov) || {}).checked }); S.pwRes = r; return true; } catch (e) { er.textContent = e.message; return false; } } }); if (res && S.pwRes) { pwDone(S.pwRes); S.pwRes = null; load(); } } function pwDone(r) { const ip = (S.pw && S.pw.ips && S.pw.ips[0]) || (C0.ips && C0.ips[0]) || 'IP-DO-SERVIDOR'; const ssh = !r.root ? '' : r.ssh === true ? '

A conta root do Linux agora usa a mesma senha' + (r.sshset ? ' e o SSH do root está ligado' : '') + '. No PC ou no celular (Termius):

ssh root@' + E(ip) + '
' : r.ssh === false ? '

A senha do root foi trocada, mas o SSH deste servidor não aceita o root com senha. Para ligar: sudo lk ssh › opção 2.

' : '

A senha do root foi trocada. O SSH não está instalado: sudo lk ssh › opção 1.

'; dialog({ title: 'Senha trocada', icon: 'check', html: '

Na próxima vez, entre com o usuário ' + E((S.pw && S.pw.user) || 'root') + ' e a senha nova.

' + ssh + (S.tfa && S.tfa.enabled ? '' : '

Dica: ative o login com 2 fatores logo abaixo (aba Internet).

'), buttons: [{ label: 'Ok', value: true, kind: 'pri' }] }); } /* ------------------------------------------------------------ 2FA */ function loadQR() { if (window.qrcode) return Promise.resolve(); return new Promise((ok, no) => { const s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js'; s.onload = ok; s.onerror = no; document.head.appendChild(s); }); } async function tfaOn() { let st; try { st = await post('tfa', 'start'); } catch (e) { return showErr('Não foi possível começar', e); } const res = await dialog({ title: 'Ativar login com 2 fatores', icon: 'lock', wide: true, html: '
  1. No celular, instale o Google Authenticator (ou Aegis, Authy, Microsoft Authenticator).
  2. No app, toque em + › Ler código QR e aponte para o código abaixo.
  3. Digite o código de 6 dígitos que aparecer no app.
' + '
Carregando o QR…
' + '
Não dá para ler? No app escolha Inserir chave e digite:
' + '
' + E(st.secret) + '
' + '' + '
', buttons: [{ label: 'Cancelar', value: null }, { label: 'Ativar', value: 'ok', kind: 'pri' }], onOpen(ov) { qs('#tfaCopy', ov).onclick = () => U.copyText(st.secret.replace(/\s/g, '')).then(() => toast('Chave copiada')); loadQR().then(() => { const q = window.qrcode(0, 'M'); q.addData(st.uri); q.make(); qs('#tfaQr', ov).innerHTML = q.createSvgTag(5, 2); }).catch(() => { qs('#tfaQr', ov).innerHTML = 'Sem internet para gerar o QR: use a chave abaixo.'; }); }, async validate(ov) { const code = qs('#tfaCode', ov).value.trim(); if (!/^\d{6}$/.test(code)) { qs('#tfaErr', ov).textContent = 'Digite os 6 números do app.'; return false; } try { const r = await post('tfa', 'enable', { code }); return { codes: r.recovery }; } catch (e) { qs('#tfaErr', ov).textContent = e.message; return false; } } }); if (res && res.codes) { await showCodes(res.codes, true); load(); } } function showCodes(codes, first) { const txt = codes.join('\n'); return dialog({ title: first ? '2FA ligado! Guarde os códigos de recuperação' : 'Códigos de recuperação novos', icon: 'check', wide: true, html: '

Se você perder o celular, cada código abaixo entra uma vez no lugar do código do app. Guarde no seu .txt ou no Bitwarden: eles não aparecem de novo.' + (first ? '' : ' Os antigos não valem mais.') + '

' + '
' + codes.map(c => '' + E(c) + '').join('') + '
' + '

Perdeu o celular e os códigos? No servidor, como root: lk 2fa off

', buttons: [{ label: 'Copiar todos', value: 'copy' }, { label: 'Já guardei', value: true, kind: 'pri' }], onOpen(ov) { qs('.df button', ov).onclick = e => { e.stopPropagation(); U.copyText(txt).then(() => toast('Códigos copiados')); }; } }); } async function tfaAsk(act) { const off = act === 'disable'; const code = await U.prompt(off ? 'Desligar o 2FA' : 'Gerar novos códigos de recuperação', 'Para confirmar, digite o código que aparece agora no app (ou um código de recuperação):', '', 'lock'); if (!code) return; try { const r = await post('tfa', act, { code }); if (off) toast('2FA desligado'); else await showCodes(r.recovery, false); } catch (e) { showErr('Não foi possível', e); } load(); } /* ------------------------------------------------------------ ações */ async function connect() { let url; try { const r = await api('login'); if (r.done) return setup(); url = r.url; } catch (e) { return showErr('Não foi possível iniciar o login', e); } let waiting = true; const res = dialog({ title: 'Conectar a conta da Cloudflare', icon: 'link', wide: true, html: '
  1. Clique no botão abaixo: abre o site da Cloudflare.
  2. Entre na sua conta (crie uma grátis se não tiver).
  3. Escolha o seu domínio na lista e clique em Authorize.
  4. Volte para esta aba: o painel continua sozinho.
' + '' + svg('open') + 'Abrir a Cloudflare e autorizar' + '
Esperando a autorização…
' + '
O link vale 15 minutos. Se a lista de domínios estiver vazia, primeiro adicione o domínio na Cloudflare (Add a site) e troque os nameservers onde você comprou.
', buttons: [{ label: 'Cancelar', value: null }], onOpen(ov, close) { const tick = async () => { if (!waiting) return; try { const d = await api('status'); if (d.cert) { waiting = false; close('ok'); return; } if (!d.login.running) { const w = qs('#cfWait', ov); if (w) w.textContent = 'O link expirou. Feche e clique em Conectar conta de novo.'; return; } } catch (e) { } setTimeout(tick, 2500); }; setTimeout(tick, 2500); } }); const v = await res; waiting = false; if (v === 'ok') { toast('Conta conectada! Criando o túnel…'); await setup(); } } async function setup() { S.busy = true; try { const r = await api('setup'); toast(r.msg); } catch (e) { showErr('Não foi possível criar o túnel', e); } S.busy = false; setTimeout(load, 2500); } async function publish(overwrite) { const v = qs('#cfDest').value, host = qs('#cfHost').value.trim().toLowerCase(), target = destTarget(v); if (!host) return toast('Escreva o endereço, por exemplo painel.' + (S.d.zone || 'seudominio.com'), 'er'); if (!target || (target.type === 'port' && !target.port)) return toast('Escolha a porta', 'er'); const b = qs('[data-a=publish]'); b.disabled = true; try { const r = await api('route_add', { host, target, overwrite: !!overwrite }); qs('#cfForm').innerHTML = ''; await dialog({ title: 'Publicado!', icon: 'check', html: '

' + E(r.msg) + '

' + E(r.url) + '

', buttons: [{ label: 'OK', value: true, kind: 'pri' }] }); load(); } catch (e) { b.disabled = false; if (e.data && e.data.exists) { const ok2 = await dialog({ title: 'Endereço já existe na Cloudflare', icon: 'info', html: '

' + E(e.message) + '

Substituir faz ' + E(host) + ' apontar para este servidor. O que estiver nele hoje sai do ar.

', buttons: [{ label: 'Cancelar', value: null }, { label: 'Substituir', value: true, kind: 'danger' }] }); if (ok2) publish(true); } else showErr('Não foi possível publicar', e); } } async function onClick(e) { const b = e.target.closest('[data-a]'); if (!b || b.disabled) return; const a = b.dataset.a; if (a === 'copy') return U.copyText(b.dataset.u).then(() => toast('Endereço copiado')); if (a === 'install') { try { const r = await post('apps', 'install', { id: 'cloudflared' }); if (window.JOBS) await window.JOBS.view(r.job, 'Instalar Cloudflare Tunnel', 'download'); } catch (er) { if (er.data && er.data.running && window.JOBS) await window.JOBS.view(er.data.running, 'App Store', 'download'); else showErr('Não foi possível instalar', er); } return load(); } if (a === 'login') return connect(); if (a === 'pw') return pwChange(); if (a === 'tfa-on') return tfaOn(); if (a === 'tfa-off') return tfaAsk('disable'); if (a === 'tfa-rec') return tfaAsk('recovery'); if (a === 'setup') return setup(); if (a === 'publish') return publish(false); if (a === 'toggle') { const on = b.dataset.on === '1'; if (!on) { const k = await dialog({ title: 'Desligar o túnel?', icon: 'power', html: '

Todos os endereços publicados saem do ar até você ligar de novo.

', buttons: [{ label: 'Cancelar', value: null }, { label: 'Desligar', value: true, kind: 'danger' }] }); if (!k) return; } try { const r = await api('toggle', { on }); toast(r.msg); } catch (er) { showErr('Falha', er); } return setTimeout(load, 3000); } if (a === 'del') { const h = b.dataset.h; const k = await dialog({ title: 'Tirar ' + h + ' do ar?', icon: 'trash', html: '

O endereço para de funcionar em alguns segundos. Os arquivos do site não são mexidos.

', buttons: [{ label: 'Cancelar', value: null }, { label: 'Remover', value: true, kind: 'danger' }] }); if (!k) return; try { const r = await api('route_del', { host: h }); toast(r.msg); } catch (er) { showErr('Falha', er); } return setTimeout(load, 1500); } if (a === 'qstart') { const t = destTarget(qs('#cfQDest').value); b.disabled = true; b.textContent = 'Gerando…'; S.qk = ''; try { await api('quick_start', { target: t }); } catch (er) { showErr('Não foi possível gerar o endereço', er); } return load(); } if (a === 'qstop') { try { await api('quick_stop'); } catch (er) { } return load(); } if (a === 'unblock') { try { await api('unblock', { ip: b.dataset.ip }); toast('IP liberado'); } catch (er) { showErr('Falha', er); } return load(); } } window.CF = { show() { if (!inited) { build(); inited = true; } load(); clearInterval(timer); timer = setInterval(() => { if (location.hash === '#internet' && !document.hidden && !qs('.fm-ov')) load(); }, 15000); } }; window.addEventListener('hashchange', () => { if (location.hash !== '#internet') clearInterval(timer); }); if (location.hash === '#internet') window.CF.show(); // acabou de trocar a senha temporária do 1º acesso if (C0.pwdone) setTimeout(() => { S.tfa = null; pwDone(C0.pwdone); }, 500); })(); LKM_EOF_CFJS cat > "$DIR/sw.js" <<'LKM_EOF_SW' /* LK Monitor - service worker: guarda só a "casca" do app (HTML de reserva, CSS, JS e ícones). Respostas das rotas ?api, ?cmd, ?fm, ?hist, ?sites, ?term e da página logada nunca vão para o cache. */ const VER = '2.3.0'; const CACHE = 'lkm-shell-' + VER; const SHELL = ['offline.html', 'manifest.webmanifest', 'icon.svg', 'icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'fm.css?v=' + VER, 'app.css?v=' + VER, 'fm.js?v=' + VER, 'term.js?v=' + VER, 'sites.js?v=' + VER, 'db.js?v=' + VER, 'config.js?v=' + VER, 'sys.js?v=' + VER, 'apps.js?v=' + VER, 'cf.js?v=' + VER]; self.addEventListener('install', e => { e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)).then(() => self.skipWaiting())); }); self.addEventListener('activate', e => { e.waitUntil(caches.keys() .then(keys => Promise.all(keys.filter(k => k.startsWith('lkm-') && k !== CACHE).map(k => caches.delete(k)))) .then(() => self.clients.claim())); }); self.addEventListener('fetch', e => { const req = e.request; if (req.method !== 'GET') return; const url = new URL(req.url); if (url.origin !== location.origin) return; const scope = new URL(self.registration.scope); if (!url.pathname.startsWith(scope.pathname)) return; // página do painel: sempre da rede; sem conexão mostra a tela de reserva if (req.mode === 'navigate') { e.respondWith(fetch(req).catch(() => caches.match('offline.html'))); return; } const key = url.pathname.slice(scope.pathname.length) + url.search; if (!SHELL.includes(key)) return; e.respondWith(caches.match(key).then(hit => hit || fetch(req))); }); LKM_EOF_SW cat > "$DIR/manifest.webmanifest" <<'LKM_EOF_MANIFEST' { "name": "LK Monitor", "short_name": "LK Monitor", "description": "Painel do servidor: uso, sites, arquivos e terminal", "lang": "pt-BR", "start_url": "./", "scope": "./", "display": "standalone", "orientation": "any", "background_color": "#eef0f3", "theme_color": "#272c33", "icons": [ { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }, { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml" } ] } LKM_EOF_MANIFEST cat > "$DIR/offline.html" <<'LKM_EOF_OFFLINE' LK Monitor · sem conexão

Sem conexão com o servidor

O LK Monitor não respondeu. Confira se você está na mesma rede do servidor e se ele está ligado.

LKM_EOF_OFFLINE cat > "$DIR/icon.svg" <<'LKM_EOF_ICON' LKM_EOF_ICON base64 -d > "$DIR/icon-192.png" <<'LKM_EOF_PNG192' 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 EbSQAAAAAElFTkSuQmCC LKM_EOF_PNG192 base64 -d > "$DIR/icon-512.png" <<'LKM_EOF_PNG512' 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 LKM_EOF_PNG512 base64 -d > "$DIR/icon-maskable-512.png" <<'LKM_EOF_PNGMASK' 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 tC+u2jYAAAAASUVORK5CYII= LKM_EOF_PNGMASK cat > "$LIBDIR/fm.php" <<'LKM_EOF_HELPER' true] + $d, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } function fail(string $m): never { echo json_encode(['ok' => false, 'error' => $m], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } function is_root(): bool { static $r = null; if ($r === null) $r = function_exists('posix_geteuid') ? posix_geteuid() === 0 : trim((string)shell_exec('id -u')) === '0'; return $r; } function norm($p): string { $p = (string)$p; if ($p === '' || $p[0] !== '/' || str_contains($p, "\0")) fail('Caminho inválido'); $parts = []; foreach (explode('/', $p) as $s) { if ($s === '' || $s === '.') continue; if ($s === '..') { array_pop($parts); continue; } $parts[] = $s; } return '/' . implode('/', $parts); } function okname($n): string { $n = trim((string)$n); if ($n === '' || $n === '.' || $n === '..' || str_contains($n, '/') || str_contains($n, "\0") || strlen($n) > 255) { fail('Nome inválido: ' . $n); } return $n; } function pjoin(string $d, string $n): string { return rtrim($d, '/') . '/' . $n; } function guard(string $p): void { if (in_array($p, PROTECTED_PATHS, true)) fail("Pasta do sistema protegida: $p"); } function paths_of(array $req): array { $list = $req['paths'] ?? []; if (!is_array($list) || !$list) fail('Nenhum item selecionado'); return array_map('norm', $list); } function exists_l(string $p): bool { return file_exists($p) || is_link($p); } function uname_of(int $uid): string { static $c = []; if (!isset($c[$uid])) { $c[$uid] = (string)$uid; if (function_exists('posix_getpwuid') && ($u = posix_getpwuid($uid))) $c[$uid] = $u['name']; else foreach (file('/etc/passwd') ?: [] as $l) { $x = explode(':', $l); if ((int)($x[2] ?? -1) === $uid) { $c[$uid] = $x[0]; break; } } } return $c[$uid]; } function gname_of(int $gid): string { static $c = []; if (!isset($c[$gid])) { $c[$gid] = (string)$gid; if (function_exists('posix_getgrgid') && ($g = posix_getgrgid($gid))) $c[$gid] = $g['name']; else foreach (file('/etc/group') ?: [] as $l) { $x = explode(':', $l); if ((int)($x[2] ?? -1) === $gid) { $c[$gid] = $x[0]; break; } } } return $c[$gid]; } function entry(string $f, ?string $name = null): array { $name ??= basename($f); $l = @lstat($f); if (!$l) return ['name' => $name, 'path' => $f, 'type' => 'file', 'size' => null, 'mtime' => 0, 'perm' => '0000', 'owner' => '?', 'group' => '?', 'link' => null, 'broken' => true]; $isLink = ($l['mode'] & 0170000) === 0120000; $st = $isLink ? (@stat($f) ?: $l) : $l; $type = ($st['mode'] & 0170000) === 0040000 ? 'dir' : 'file'; return [ 'name' => $name, 'path' => $f, 'type' => $type, 'size' => $type === 'dir' ? null : $st['size'], 'mtime' => $st['mtime'], 'perm' => sprintf('%04o', $st['mode'] & 07777), 'owner' => uname_of($st['uid']), 'group' => gname_of($st['gid']), 'link' => $isLink ? (string)@readlink($f) : null, 'broken' => $isLink && !@stat($f), 'exec' => $type === 'file' && ($st['mode'] & 0111) !== 0, ]; } function owner_like(string $target, string $ref): void { if (!is_root()) return; $st = @stat($ref); if (!$st || ($st['uid'] === 0 && $st['gid'] === 0)) return; chown_r($target, $st['uid'], $st['gid']); } function chown_r(string $p, $uid, $gid): void { @lchown($p, $uid); @lchgrp($p, $gid); if (is_dir($p) && !is_link($p)) { foreach (scandir($p) ?: [] as $n) { if ($n !== '.' && $n !== '..') chown_r(pjoin($p, $n), $uid, $gid); } } } function rm_rf(string $p): void { if (is_link($p) || !is_dir($p)) { if (!@unlink($p)) fail('Não foi possível excluir: ' . $p); return; } foreach (scandir($p) ?: [] as $n) { if ($n !== '.' && $n !== '..') rm_rf(pjoin($p, $n)); } if (!@rmdir($p)) fail('Não foi possível excluir a pasta: ' . $p); } function keep_meta(string $s, string $d): void { $st = @lstat($s); if (!$st) return; if (!is_link($d)) { @chmod($d, $st['mode'] & 07777); @touch($d, $st['mtime']); } if (is_root()) { @lchown($d, $st['uid']); @lchgrp($d, $st['gid']); } } function cp_r(string $s, string $d): void { if (is_link($s)) { if (!@symlink((string)readlink($s), $d)) fail('Falha ao copiar o link: ' . $s); keep_meta($s, $d); return; } if (is_dir($s)) { if (!@mkdir($d, 0755)) fail('Falha ao criar a pasta: ' . $d); foreach (scandir($s) ?: [] as $n) { if ($n !== '.' && $n !== '..') cp_r(pjoin($s, $n), pjoin($d, $n)); } keep_meta($s, $d); return; } if (!@copy($s, $d)) fail('Falha ao copiar: ' . $s); keep_meta($s, $d); } function uniq(string $dir, string $name, bool $copy = true): string { $t = pjoin($dir, $name); if (!exists_l($t)) return $t; $dot = strpos($name, '.', 1); $base = $dot === false ? $name : substr($name, 0, $dot); $ext = $dot === false ? '' : substr($name, $dot); for ($i = $copy ? 1 : 2; $i < 1000; $i++) { $suffix = $copy ? ' (cópia' . ($i > 1 ? " $i" : '') . ')' : " ($i)"; $t = pjoin($dir, $base . $suffix . $ext); if (!exists_l($t)) return $t; } fail('Não foi possível gerar um nome livre'); } function inside(string $child, string $parent): bool { return $child === $parent || str_starts_with($child, rtrim($parent, '/') . '/'); } function mime_of(string $f): string { $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION)); $map = [ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml', 'ico' => 'image/x-icon', 'bmp' => 'image/bmp', 'avif' => 'image/avif', 'pdf' => 'application/pdf', 'mp4' => 'video/mp4', 'webm' => 'video/webm', 'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg', 'wav' => 'audio/wav', 'txt' => 'text/plain', 'log' => 'text/plain', ]; if (isset($map[$ext])) return $map[$ext]; if (function_exists('mime_content_type') && is_file($f)) return (string)(@mime_content_type($f) ?: 'application/octet-stream'); return 'application/octet-stream'; } function is_utf8(string $s): bool { return function_exists('mb_check_encoding') ? mb_check_encoding($s, 'UTF-8') : (bool)preg_match('//u', $s); } function latin1_to_utf8(string $s): string { return function_exists('mb_convert_encoding') ? mb_convert_encoding($s, 'UTF-8', 'ISO-8859-1') : utf8_encode($s); } function utf8_to_latin1(string $s): string { return function_exists('mb_convert_encoding') ? mb_convert_encoding($s, 'ISO-8859-1', 'UTF-8') : utf8_decode($s); } function list_new(array $before, string $dir): array { $now = array_diff(scandir($dir) ?: [], ['.', '..']); return array_values(array_diff($now, $before)); } /* ------------------------------------------------------------------ ações */ $req = json_decode((string)stream_get_contents(STDIN), true); if (!is_array($req)) fail('Requisição inválida'); $action = (string)($req['action'] ?? ''); switch ($action) { case 'list': { $p = norm($req['path'] ?? '/'); if (!is_dir($p)) fail('Pasta não encontrada: ' . $p); $dh = @opendir($p); if (!$dh) fail('Sem permissão para abrir: ' . $p); $items = []; $trunc = false; while (($n = readdir($dh)) !== false) { if ($n === '.' || $n === '..') continue; if (count($items) >= 10000) { $trunc = true; break; } $items[] = entry(pjoin($p, $n), $n); } closedir($dh); out(['path' => $p, 'items' => $items, 'truncated' => $trunc, 'writable' => is_writable($p), 'free' => @disk_free_space($p) ?: 0, 'total' => @disk_total_space($p) ?: 0]); } case 'stat': { $p = norm($req['path'] ?? ''); if (!exists_l($p)) fail('Arquivo não encontrado: ' . $p); $e = entry($p); $e['is_dir'] = $e['type'] === 'dir'; $e['mime'] = $e['is_dir'] ? 'inode/directory' : mime_of($p); $e['readable'] = is_readable($p); $e['atime'] = @fileatime($p) ?: 0; if (!empty($req['du']) && $e['is_dir']) { $du = trim((string)shell_exec('timeout 25 du -sb --apparent-size ' . escapeshellarg($p) . ' 2>/dev/null')); $e['size'] = $du !== '' ? (int)$du : null; $cnt = trim((string)shell_exec('timeout 25 find ' . escapeshellarg($p) . ' -mindepth 1 2>/dev/null | wc -l')); $e['count'] = (int)$cnt; } out($e); } case 'cat': { $p = norm($req['path'] ?? ''); if (!is_file($p) || !is_readable($p)) exit(2); $h = fopen($p, 'rb'); if (!$h) exit(2); $o = fopen('php://stdout', 'wb'); stream_copy_to_stream($h, $o); exit(0); } case 'read': { $p = norm($req['path'] ?? ''); if (!is_file($p)) fail('Arquivo não encontrado: ' . $p); if (filesize($p) > 5 * 1024 * 1024) fail('Arquivo muito grande para editar no navegador (máximo 5 MB).'); $c = @file_get_contents($p); if ($c === false) fail('Sem permissão de leitura: ' . $p); if (str_contains(substr($c, 0, 8192), "\0")) fail('Este arquivo é binário e não pode ser editado como texto.'); $enc = 'utf8'; if (!is_utf8($c)) { $c = latin1_to_utf8($c); $enc = 'latin1'; } clearstatcache(); out(['content' => $c, 'enc' => $enc, 'mtime' => filemtime($p), 'size' => filesize($p), 'writable' => is_writable($p)]); } case 'save': { $p = norm($req['path'] ?? ''); $c = (string)($req['content'] ?? ''); if (($req['enc'] ?? '') === 'latin1') $c = utf8_to_latin1($c); $dir = dirname($p); if (!is_dir($dir)) fail('Pasta não encontrada: ' . $dir); if (is_dir($p)) fail('O destino é uma pasta'); clearstatcache(); if (is_file($p) && isset($req['mtime']) && empty($req['force']) && filemtime($p) !== (int)$req['mtime']) { fail('CONFLICT'); } if (is_link($p) || (is_file($p) && @stat($p)['nlink'] > 1)) { if (@file_put_contents($p, $c) === false) fail('Sem permissão para salvar: ' . $p); } else { $st = is_file($p) ? @stat($p) : null; $tmp = pjoin($dir, '.lkm-' . bin2hex(random_bytes(4)) . '.tmp'); if (@file_put_contents($tmp, $c) === false) fail('Sem permissão para salvar em ' . $dir); if ($st) { @chmod($tmp, $st['mode'] & 07777); if (is_root()) { @chown($tmp, $st['uid']); @chgrp($tmp, $st['gid']); } } else { @chmod($tmp, 0644); } if (!@rename($tmp, $p)) { @unlink($tmp); fail('Falha ao salvar: ' . $p); } if (!$st) owner_like($p, $dir); } clearstatcache(); out(['mtime' => filemtime($p), 'size' => filesize($p)]); } case 'mkdir': case 'newfile': { $dir = norm($req['path'] ?? ''); $name = okname($req['name'] ?? ''); if (!is_dir($dir)) fail('Pasta não encontrada: ' . $dir); $t = pjoin($dir, $name); if (exists_l($t)) fail("Já existe um item chamado \"$name\""); if ($action === 'mkdir') { if (!@mkdir($t, 0755)) fail('Sem permissão para criar a pasta em ' . $dir); } else { if (@file_put_contents($t, '') === false) fail('Sem permissão para criar o arquivo em ' . $dir); @chmod($t, 0644); } owner_like($t, $dir); out(['path' => $t]); } case 'rename': { $s = norm($req['path'] ?? ''); guard($s); if (!exists_l($s)) fail('Item não encontrado: ' . $s); $name = okname($req['name'] ?? ''); $t = pjoin(dirname($s), $name); if ($t === $s) out(['path' => $t]); if (exists_l($t) && strcasecmp($t, $s) !== 0) fail("Já existe um item chamado \"$name\""); if (!@rename($s, $t)) fail('Não foi possível renomear'); out(['path' => $t]); } case 'delete': { $n = 0; foreach (paths_of($req) as $p) { guard($p); if (!exists_l($p)) continue; rm_rf($p); $n++; } out(['count' => $n]); } case 'copy': case 'move': { $dest = norm($req['dest'] ?? ''); if (!is_dir($dest)) fail('Pasta de destino não encontrada: ' . $dest); $done = []; foreach (paths_of($req) as $s) { if (!exists_l($s)) fail('Item não encontrado: ' . $s); if (is_dir($s) && !is_link($s) && inside($dest, $s)) fail('Não é possível colocar uma pasta dentro dela mesma.'); if ($action === 'move') { guard($s); if (dirname($s) === $dest) continue; $t = uniq($dest, basename($s), false); if (!@rename($s, $t)) { cp_r($s, $t); rm_rf($s); } } else { $t = uniq($dest, basename($s)); cp_r($s, $t); } $done[] = $t; } out(['paths' => $done]); } case 'chmod': { $mode = (string)($req['mode'] ?? ''); $dmode = (string)($req['dmode'] ?? $mode); if (!preg_match('/^[0-7]{3,4}$/', $mode) || !preg_match('/^[0-7]{3,4}$/', $dmode)) fail('Permissão inválida'); $rec = !empty($req['recursive']); $m = octdec($mode); $dm = octdec($dmode); $err = 0; $apply = function (string $p, bool $top) use (&$apply, $m, $dm, $rec, &$err): void { if (is_link($p)) return; $isDir = is_dir($p); if (!@chmod($p, ($isDir && ($rec || !$top)) ? $dm : $m)) $err++; if ($rec && $isDir) { foreach (scandir($p) ?: [] as $n) { if ($n !== '.' && $n !== '..') $apply(pjoin($p, $n), false); } } }; foreach (paths_of($req) as $p) { if ($rec) guard($p); if (!exists_l($p)) fail('Item não encontrado: ' . $p); if (is_dir($p) && !$rec) { if (!@chmod($p, $m)) $err++; continue; } $apply($p, true); } if ($err) fail("Permissão alterada com $err erro(s). Verifique se você tem acesso."); out(); } case 'chown': { if (!is_root()) fail('Somente o root pode mudar o dono dos arquivos.'); $u = trim((string)($req['owner'] ?? '')); $g = trim((string)($req['group'] ?? '')); $re = '/^[A-Za-z0-9_][A-Za-z0-9_.-]*\$?$/'; if (($u !== '' && !preg_match($re, $u)) || ($g !== '' && !preg_match($re, $g))) fail('Dono ou grupo inválido'); if ($u !== '' && !ctype_digit($u) && function_exists('posix_getpwnam') && !posix_getpwnam($u)) fail("Usuário \"$u\" não existe"); if ($g !== '' && !ctype_digit($g) && function_exists('posix_getgrnam') && !posix_getgrnam($g)) fail("Grupo \"$g\" não existe"); $rec = !empty($req['recursive']); $uid = $u === '' ? null : (ctype_digit($u) ? (int)$u : $u); $gid = $g === '' ? null : (ctype_digit($g) ? (int)$g : $g); $apply = function (string $p) use (&$apply, $uid, $gid, $rec): void { if ($uid !== null) @lchown($p, $uid); if ($gid !== null) @lchgrp($p, $gid); if ($rec && is_dir($p) && !is_link($p)) { foreach (scandir($p) ?: [] as $n) { if ($n !== '.' && $n !== '..') $apply(pjoin($p, $n)); } } }; foreach (paths_of($req) as $p) { if ($rec) guard($p); if (!exists_l($p)) fail('Item não encontrado: ' . $p); $apply($p); } out(); } case 'upload': { $tmp = (string)($req['tmp'] ?? ''); $dest = norm($req['dest'] ?? ''); $name = okname($req['name'] ?? ''); if ($tmp === '' || !is_file($tmp)) fail('Arquivo enviado não encontrado'); if (!is_dir($dest)) fail('Pasta de destino não encontrada: ' . $dest); $t = pjoin($dest, $name); if (exists_l($t)) { if (!empty($req['overwrite'])) { if (is_dir($t) && !is_link($t)) fail("Já existe uma pasta chamada \"$name\""); } else { $t = uniq($dest, $name, false); } } $st = is_file($t) ? @stat($t) : null; if (!@copy($tmp, $t)) fail('Sem permissão para gravar em ' . $dest); if ($st) { @chmod($t, $st['mode'] & 07777); if (is_root()) { @chown($t, $st['uid']); @chgrp($t, $st['gid']); } } else { @chmod($t, 0644); owner_like($t, $dest); } out(['path' => $t]); } case 'zip': { if (!class_exists('ZipArchive')) fail('A extensão zip do PHP não está instalada (apt install php-zip).'); $dest = norm($req['dest'] ?? ''); $name = okname($req['name'] ?? 'arquivo.zip'); if (!preg_match('/\.zip$/i', $name)) $name .= '.zip'; if (!is_dir($dest)) fail('Pasta não encontrada: ' . $dest); $t = pjoin($dest, $name); if (exists_l($t)) fail("Já existe um arquivo chamado \"$name\""); $z = new ZipArchive(); if ($z->open($t, ZipArchive::CREATE) !== true) fail('Sem permissão para criar o zip em ' . $dest); foreach (paths_of($req) as $s) { if (!exists_l($s)) continue; $base = basename($s); if (is_dir($s) && !is_link($s)) { $z->addEmptyDir($base); $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($s, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST); foreach ($it as $f) { $rel = $base . '/' . substr($f->getPathname(), strlen($s) + 1); if ($f->isDir() && !$f->isLink()) $z->addEmptyDir($rel); elseif ($f->isFile() && $f->isReadable()) $z->addFile($f->getPathname(), $rel); } } elseif (is_file($s)) { $z->addFile($s, $base); } } if (!$z->close()) fail('Falha ao gravar o arquivo zip'); owner_like($t, $dest); out(['path' => $t]); } case 'zipdl': { if (!class_exists('ZipArchive')) fail('A extensão zip do PHP não está instalada (apt install php-zip).'); $tmpdir = norm($req['tmpdir'] ?? '/tmp'); $list = isset($req['paths']) ? paths_of($req) : [norm($req['path'] ?? '')]; $t = pjoin($tmpdir, 'dl-' . bin2hex(random_bytes(6)) . '.zip'); $z = new ZipArchive(); if ($z->open($t, ZipArchive::CREATE) !== true) fail('Falha ao preparar o download'); foreach ($list as $s) { if (!exists_l($s)) continue; $base = basename($s) ?: 'raiz'; if (is_file($s)) { $z->addFile($s, $base); continue; } if (!is_dir($s)) continue; $z->addEmptyDir($base); $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($s, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST); foreach ($it as $f) { $rel = $base . '/' . substr($f->getPathname(), strlen(rtrim($s, '/')) + 1); if ($f->isDir() && !$f->isLink()) $z->addEmptyDir($rel); elseif ($f->isFile() && $f->isReadable()) $z->addFile($f->getPathname(), $rel); } } if ($z->numFiles === 0) { $z->close(); @unlink($t); fail('Nada para baixar'); } if (!$z->close()) fail('Falha ao preparar o download'); @chmod($t, 0644); $name = count($list) === 1 ? (basename($list[0]) ?: 'raiz') . '.zip' : 'arquivos.zip'; out(['file' => $t, 'name' => $name]); } case 'extract': { $s = norm($req['path'] ?? ''); if (!is_file($s)) fail('Arquivo não encontrado: ' . $s); $dir = dirname($s); $lower = strtolower($s); $isZip = str_ends_with($lower, '.zip'); $isTar = (bool)preg_match('/\.(tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/', $lower); if (!$isZip && !$isTar) fail('Formato não suportado (use .zip, .tar, .tar.gz, .tgz, .tar.bz2, .tar.xz)'); if (!empty($req['here'])) { $target = $dir; } else { $stem = preg_replace('/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i', '', basename($s)); $target = uniq($dir, $stem !== '' ? $stem : 'extraido', false); if (!@mkdir($target, 0755)) fail('Sem permissão para criar a pasta em ' . $dir); } $before = array_diff(scandir($target) ?: [], ['.', '..']); if ($isZip) { if (!class_exists('ZipArchive')) fail('A extensão zip do PHP não está instalada (apt install php-zip).'); $z = new ZipArchive(); if ($z->open($s) !== true) fail('Arquivo zip inválido ou corrompido'); for ($i = 0; $i < $z->numFiles; $i++) { $n = (string)$z->getNameIndex($i); if (str_starts_with($n, '/') || preg_match('#(^|/)\.\.(/|$)#', $n)) { $z->close(); fail('Zip com caminho inseguro: ' . $n); } } if (!$z->extractTo($target)) { $z->close(); fail('Falha ao extrair'); } $z->close(); } else { $cmd = 'tar -xf ' . escapeshellarg($s) . ' -C ' . escapeshellarg($target) . ' --no-same-owner 2>&1'; exec($cmd, $o, $rc); if ($rc !== 0) fail('Falha ao extrair: ' . implode(' ', array_slice($o, 0, 3))); } if (empty($req['here'])) owner_like($target, $dir); else foreach (list_new($before, $target) as $n) owner_like(pjoin($target, $n), $target); out(['path' => $target]); } case 'search': { $root = norm($req['path'] ?? '/'); $q = trim((string)($req['q'] ?? '')); if ($q === '') fail('Digite o que procurar'); if (!is_dir($root)) fail('Pasta não encontrada'); $skip = ['/proc', '/sys', '/dev', '/run', '/snap']; $res = []; $limit = 300; $deadline = microtime(true) + 12; $stack = [[$root, 0]]; while ($stack && count($res) < $limit && microtime(true) < $deadline) { [$d, $depth] = array_pop($stack); $dh = @opendir($d); if (!$dh) continue; while (($n = readdir($dh)) !== false) { if ($n === '.' || $n === '..') continue; $f = pjoin($d, $n); if (stripos($n, $q) !== false) { $res[] = entry($f, $n); if (count($res) >= $limit) break; } if ($depth < 12 && is_dir($f) && !is_link($f) && !in_array($f, $skip, true)) $stack[] = [$f, $depth + 1]; } closedir($dh); } out(['items' => $res, 'limited' => count($res) >= $limit || microtime(true) >= $deadline]); } default: fail('Ação desconhecida: ' . $action); } LKM_EOF_HELPER cat > "$LIBDIR/lib.php" <<'LKM_EOF_LIB' /dev/null'); return is_string($o) ? $o : ''; } function temps(): array { $list = []; foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $h) { $chip = trim(rf("$h/name")); foreach (glob("$h/temp*_input") ?: [] as $f) { $v = (int)trim(rf($f)); if ($v <= 0 || $v > 150000) continue; $lab = trim(rf(str_replace('_input', '_label', $f))); $list[] = ['chip' => $chip, 'label' => $lab !== '' ? $lab : $chip, 'c' => round($v / 1000, 1)]; } } $cpu = null; foreach ($list as $t) { if (in_array($t['label'], ['Package id 0', 'Tctl', 'Tdie'], true)) { $cpu = $t['c']; break; } } if ($cpu === null) { foreach ($list as $t) { if (in_array($t['chip'], ['coretemp', 'k10temp', 'zenpower', 'cpu_thermal', 'x86_pkg_temp', 'acpitz'], true)) $cpu = max($cpu ?? 0, $t['c']); } } if ($cpu === null && $list) $cpu = max(array_column($list, 'c')); if ($cpu === null) { foreach (glob('/sys/class/thermal/thermal_zone*/temp') ?: [] as $f) { $v = (int)trim(rf($f)); if ($v > 0) $cpu = max($cpu ?? 0, round($v / 1000, 1)); } } usort($list, fn($a, $b) => $b['c'] <=> $a['c']); return ['cpu' => $cpu, 'list' => array_slice($list, 0, 10)]; } function disks(): array { $ok = ['ext4', 'ext3', 'ext2', 'xfs', 'btrfs', 'vfat', 'exfat', 'ntfs', 'ntfs3', 'fuseblk', 'f2fs', 'zfs']; $seen = []; $out = []; foreach (explode("\n", rf('/proc/mounts')) as $l) { $p = explode(' ', $l); if (count($p) < 3 || !in_array($p[2], $ok, true)) continue; $dev = $p[0]; $mnt = str_replace('\040', ' ', $p[1]); if (isset($seen[$dev]) || str_starts_with($mnt, '/snap/')) continue; $t = @disk_total_space($mnt); $f = @disk_free_space($mnt); if (!$t) continue; $seen[$dev] = 1; $out[] = ['mnt' => $mnt, 'dev' => basename($dev), 'fs' => $p[2], 'total' => $t, 'used' => $t - $f]; } return $out; } function tcp_established(): int { $n = 0; foreach (['/proc/net/tcp', '/proc/net/tcp6'] as $f) { foreach (array_slice(explode("\n", rf($f)), 1) as $l) { $p = preg_split('/\s+/', trim($l)); if (($p[3] ?? '') === '01') $n++; } } return $n; } function meminfo(): array { $mi = []; foreach (explode("\n", rf('/proc/meminfo')) as $l) { if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mi[$m[1]] = (int)$m[2] * 1024; } return $mi; } function skip_iface(string $if): bool { return $if === 'lo' || (bool)preg_match('/^(veth|docker|br-|virbr|vnet)/', $if); } /** Contadores acumulados de CPU (total/ocioso) e rede (bytes recebidos/enviados), para calcular taxas. */ function sys_counters(): array { $cpu = [0, 0]; if (preg_match('/^cpu\s+(.*)$/m', rf('/proc/stat'), $m)) { $v = array_map('intval', preg_split('/\s+/', trim($m[1]))); $cpu = [array_sum(array_slice($v, 0, 8)), ($v[3] ?? 0) + ($v[4] ?? 0)]; } $rx = 0; $tx = 0; foreach (explode("\n", rf('/proc/net/dev')) as $l) { if (!preg_match('/^\s*([^:\s]+):\s*(.*)$/', $l, $m) || skip_iface($m[1])) continue; $p = preg_split('/\s+/', trim($m[2])); $rx += (int)($p[0] ?? 0); $tx += (int)($p[8] ?? 0); } return ['t' => microtime(true), 'cpu' => $cpu, 'rx' => $rx, 'tx' => $tx]; } /* ------------------------------------------------------------------ histórico (SQLite) */ function metrics_file(): string { return (defined('LKM_DATA') ? LKM_DATA : (getenv('LKM_DATA') ?: '/var/lib/lkmonitor')) . '/metrics.sqlite'; } function metrics_db(bool $create = false): ?PDO { if (!class_exists('PDO') || !in_array('sqlite', PDO::getAvailableDrivers(), true)) return null; $f = metrics_file(); if (!$create && !is_file($f)) return null; try { $db = new PDO('sqlite:' . $f, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 10]); $db->exec('PRAGMA busy_timeout=10000'); if ($create) { $db->exec('PRAGMA journal_mode=WAL'); $db->exec('CREATE TABLE IF NOT EXISTS m (ts INTEGER PRIMARY KEY, cpu REAL, l1 REAL, l5 REAL, l15 REAL, mem INTEGER, mem_total INTEGER, swap INTEGER, swap_total INTEGER, temp REAL, rx REAL, tx REAL, tcp INTEGER)'); $db->exec('CREATE TABLE IF NOT EXISTS disk (ts INTEGER NOT NULL, mnt TEXT NOT NULL, used INTEGER, total INTEGER, PRIMARY KEY (ts, mnt)) WITHOUT ROWID'); } return $db; } catch (Throwable $e) { return null; } } /** Histórico agrupado para os gráficos. $range: 1h, 24h ou 7d. Cada série vem com média, mínimo e máximo por intervalo. */ function metrics_history(string $range): array { $cfg = ['1h' => [3600, 60], '24h' => [86400, 300], '7d' => [604800, 1800]]; if (!isset($cfg[$range])) return ['ok' => false, 'error' => 'Período inválido']; [$span, $step] = $cfg[$range]; $db = metrics_db(); if (!$db) { $why = class_exists('PDO') && in_array('sqlite', PDO::getAvailableDrivers(), true) ? 'empty' : 'sqlite'; return ['ok' => false, 'error' => $why]; } $now = time(); $since = $now - $span; $cols = [ 'cpu' => 'cpu', 'mem' => 'CASE WHEN mem_total > 0 THEN mem * 100.0 / mem_total END', 'rx' => 'rx', 'tx' => 'tx', 'temp' => 'temp', 'load' => 'l1', 'swap' => 'CASE WHEN swap_total > 0 THEN swap * 100.0 / swap_total END', 'tcp' => 'tcp', ]; $sel = ["(ts / $step) * $step AS b"]; foreach ($cols as $k => $expr) $sel[] = "AVG($expr) AS {$k}_a, MIN($expr) AS {$k}_lo, MAX($expr) AS {$k}_hi"; try { $st = $db->query('SELECT ' . implode(', ', $sel) . " FROM m WHERE ts >= $since GROUP BY b ORDER BY b"); $rows = $st->fetchAll(PDO::FETCH_ASSOC); $last = $db->query('SELECT MAX(ts) FROM m')->fetchColumn(); } catch (Throwable $e) { return ['ok' => false, 'error' => 'empty']; } $t = []; $s = []; foreach ($cols as $k => $_) $s[$k] = ['a' => [], 'lo' => [], 'hi' => []]; $r1 = fn($v) => $v === null ? null : round((float)$v, 1); foreach ($rows as $r) { $t[] = (int)$r['b'] + intdiv($step, 2); foreach ($cols as $k => $_) { $s[$k]['a'][] = $r1($r[$k . '_a']); $s[$k]['lo'][] = $r1($r[$k . '_lo']); $s[$k]['hi'][] = $r1($r[$k . '_hi']); } } return ['ok' => true, 'range' => $range, 'step' => $step, 'from' => $since, 'to' => $now, 'last' => $last ? (int)$last : null, 't' => $t, 's' => $s]; } LKM_EOF_LIB cat > "$LIBDIR/collect.php" <<'LKM_EOF_COLLECT' 600 || $cur['t'] <= $prev['t']) { // primeira coleta (ou depois de muito tempo parado): mede 1 segundo $prev = $cur; sleep(1); $cur = sys_counters(); } @file_put_contents($stateFile, json_encode($cur), LOCK_EX); $dt = max(0.5, $cur['t'] - $prev['t']); $dTot = $cur['cpu'][0] - $prev['cpu'][0]; $cpu = $dTot > 0 ? round(max(0, min(100, 100 * (1 - ($cur['cpu'][1] - $prev['cpu'][1]) / $dTot))), 1) : null; $rate = fn(int $a, int $b) => $a >= $b ? round(($a - $b) / $dt, 1) : null; // contador zerado (reboot/interface nova) $rx = $rate($cur['rx'], $prev['rx']); $tx = $rate($cur['tx'], $prev['tx']); $mi = meminfo(); $memTotal = $mi['MemTotal'] ?? 0; $load = array_map('floatval', array_slice(explode(' ', rf('/proc/loadavg')), 0, 3)); $temp = temps()['cpu']; $disks = disks(); $now = time(); $db = metrics_db(true); if (!$db) { fwrite(STDERR, "LK Monitor: SQLite indisponível (instale php-sqlite3)\n"); exit(1); } try { $db->beginTransaction(); $st = $db->prepare('INSERT OR REPLACE INTO m (ts, cpu, l1, l5, l15, mem, mem_total, swap, swap_total, temp, rx, tx, tcp) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'); $st->execute([ $now, $cpu, $load[0] ?? null, $load[1] ?? null, $load[2] ?? null, $memTotal - ($mi['MemAvailable'] ?? 0), $memTotal, ($mi['SwapTotal'] ?? 0) - ($mi['SwapFree'] ?? 0), $mi['SwapTotal'] ?? 0, $temp, $rx, $tx, tcp_established(), ]); $sd = $db->prepare('INSERT OR REPLACE INTO disk (ts, mnt, used, total) VALUES (?,?,?,?)'); foreach ($disks as $d) $sd->execute([$now, $d['mnt'], (int)$d['used'], (int)$d['total']]); $old = $now - KEEP_DAYS * 86400; $db->exec("DELETE FROM m WHERE ts < $old"); $db->exec("DELETE FROM disk WHERE ts < $old"); $db->commit(); if ((int)date('i') === 0) $db->exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (Throwable $e) { if ($db->inTransaction()) $db->rollBack(); fwrite(STDERR, 'LK Monitor: falha ao gravar métricas: ' . $e->getMessage() . "\n"); exit(1); } // alertas: avaliados a cada coleta (um erro aqui não afeta o histórico) try { require __DIR__ . '/alerts.inc.php'; al_eval(['temp' => $temp, 'mem_pct' => $memTotal ? ($memTotal - ($mi['MemAvailable'] ?? 0)) / $memTotal * 100 : null, 'disks' => $disks], $db); } catch (Throwable $e) { fwrite(STDERR, 'LK Monitor: falha ao avaliar alertas: ' . $e->getMessage() . "\n"); } LKM_EOF_COLLECT cat > "$LIBDIR/sites.php" <<'LKM_EOF_SITES' ) do comando lk. */ declare(strict_types=1); if (PHP_SAPI !== 'cli') { http_response_code(404); exit; } error_reporting(E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED); ini_set('display_errors', 'stderr'); umask(022); set_time_limit(0); putenv('PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'); putenv('LANG=C.UTF-8'); const AV = '/etc/apache2/sites-available'; const EN = '/etc/apache2/sites-enabled'; const WWW = '/var/www'; const APACHE_LOG = '/var/log/apache2'; const SKIP = ['000-default', 'default-ssl', 'lkmonitor']; const LK_DBFILE = '/etc/lk/databases.txt'; require __DIR__ . '/jobs.inc.php'; function out(array $d = []): never { echo json_encode(['ok' => true] + $d, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } function fail(string $m, array $extra = []): never { echo json_encode(['ok' => false, 'error' => $m] + $extra, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } function is_root(): bool { return function_exists('posix_geteuid') ? posix_geteuid() === 0 : trim((string)shell_exec('id -u')) === '0'; } function need_root(): void { if (!is_root()) fail('Somente leitura: o painel não tem acesso root para alterar sites.'); } /** Executa um comando (lista de argumentos, sem shell). Devolve [código, saída]. */ function run(array $argv, ?string $stdin = null): array { $p = proc_open($argv, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['redirect', 1]], $pipes); if (!is_resource($p)) return [127, 'falha ao executar ' . $argv[0]]; if ($stdin !== null) fwrite($pipes[0], $stdin); fclose($pipes[0]); $o = stream_get_contents($pipes[1]); fclose($pipes[1]); return [proc_close($p), trim((string)$o)]; } /** Mesmas regras do lk: minúsculas, sem http(s)://, sem caminho e sem www. no início. */ function norm_domain($d): string { $d = strtolower(trim((string)$d)); $d = preg_replace('#^https?://#', '', $d); $d = explode('/', $d)[0]; if (str_starts_with($d, 'www.')) $d = substr($d, 4); return $d; } function valid_domain(string $d): bool { return strlen($d) <= 200 && (bool)preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/', $d); } function site_name($n): string { $n = (string)$n; if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/', $n) || in_array($n, SKIP, true) || str_ends_with($n, '-le-ssl')) fail('Site inválido'); if (!is_file(AV . "/$n.conf")) fail("Site não encontrado: $n"); return $n; } function php_versions(): array { $v = []; foreach (glob('/etc/php/*/fpm', GLOB_ONLYDIR) ?: [] as $d) { $x = basename(dirname($d)); if (preg_match('/^\d+\.\d+$/', $x)) $v[] = $x; } usort($v, 'version_compare'); return $v; } function server_ip(): string { foreach (preg_split('/\s+/', trim((string)shell_exec('hostname -I 2>/dev/null'))) as $ip) { if ($ip !== '' && !str_contains($ip, ':') && !str_starts_with($ip, '172.17.') && !str_starts_with($ip, '192.168.122.')) return $ip; } return '127.0.0.1'; } function apache_log_path(string $p): string { $p = trim($p, "\"' "); $p = str_replace(['${APACHE_LOG_DIR}', '$APACHE_LOG_DIR'], APACHE_LOG, $p); return str_starts_with($p, '/') ? $p : '/var/log/apache2/' . ltrim($p, '/'); } function site_info(string $n): array { $c = (string)@file_get_contents(AV . "/$n.conf"); $get = function (string $dir) use ($c): array { preg_match_all('/^\s*' . $dir . '\s+(.+?)\s*$/mi', $c, $m); return $m[1]; }; $doc = trim((string)($get('DocumentRoot')[0] ?? ''), '"'); $proxy = null; foreach ($get('ProxyPass') as $pp) { $parts = preg_split('/\s+/', $pp); if (($parts[0] ?? '') === '/' && isset($parts[1])) { $proxy = $parts[1]; break; } } $php = preg_match('#php(\d+\.\d+)-fpm\.sock#', $c, $m) ? $m[1] : null; $root = $doc; if (preg_match('#/(public_html|public)/?$#', $doc)) $root = dirname(rtrim($doc, '/')); if ($proxy && ($root === '' || $root === WWW)) $root = WWW . "/$n"; $type = $proxy ? 'proxy' : ((preg_match('#/public/?$#', $doc) || is_file("$root/artisan")) ? 'laravel' : 'php'); $aliases = []; foreach ($get('ServerAlias') as $a) foreach (preg_split('/\s+/', $a) as $x) if ($x !== '') $aliases[] = $x; $err = $get('ErrorLog')[0] ?? ''; return [ 'name' => $n, 'domain' => trim((string)($get('ServerName')[0] ?? $n)), 'aliases' => $aliases, 'on' => file_exists(EN . "/$n.conf") || is_link(EN . "/$n.conf"), 'ssl' => is_file(AV . "/$n-le-ssl.conf"), 'type' => $type, 'php' => $proxy ? null : $php, 'proxy' => $proxy, 'docroot' => $doc, 'root' => $root, 'exists' => $root !== '' && is_dir($root), 'errlog' => $err !== '' ? apache_log_path($err) : null, 'lk' => str_contains($c, 'gerado por LK'), ]; } function site_names(): array { $out = []; foreach (glob(AV . '/*.conf') ?: [] as $f) { $n = basename($f, '.conf'); if (in_array($n, SKIP, true) || str_ends_with($n, '-le-ssl')) continue; $out[] = $n; } natcasesort($out); return array_values($out); } function apache_test(): array { [$rc, $o] = run(['apache2ctl', 'configtest']); return [$rc === 0, $o]; } function apache_reload(): void { [$ok, $o] = apache_test(); if (!$ok) fail('Configuração do Apache com erro, nada foi recarregado.', ['detail' => $o]); [$rc, $o] = run(['systemctl', 'reload', 'apache2']); if ($rc !== 0) fail('Falha ao recarregar o Apache.', ['detail' => $o]); } function mysql_ok(): bool { if (!is_root() || !is_executable('/usr/bin/mysql')) return false; [$rc] = run(['mysql', '--protocol=socket', '-uroot', '-N', '-e', 'SELECT 1']); return $rc === 0; } function gen_pass(int $n = 20): string { $a = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; $s = ''; for ($i = 0; $i < $n; $i++) $s .= $a[random_int(0, strlen($a) - 1)]; return $s; } function rm_tree(string $p): void { run(['rm', '-rf', '--one-file-system', '--', $p]); } /** Últimas $n linhas de um arquivo (lendo só o final), opcionalmente filtrando por texto. */ function tail_file(string $f, int $n, string $q = ''): array { $size = (int)@filesize($f); $h = @fopen($f, 'rb'); if (!$h) return [null, 0]; $chunk = $q === '' ? min($size, max(65536, $n * 600)) : min($size, 8 * 1024 * 1024); fseek($h, $size - $chunk); $data = (string)fread($h, $chunk); fclose($h); $lines = explode("\n", rtrim($data, "\n")); if ($chunk < $size) array_shift($lines); if ($q !== '') $lines = array_values(array_filter($lines, fn($l) => stripos($l, $q) !== false)); $total = count($lines); return [implode("\n", array_slice($lines, -$n)), $total]; } /* ------------------------------------------------------------------ ações */ $req = json_decode((string)stream_get_contents(STDIN), true); if (!is_array($req)) fail('Requisição inválida'); $action = (string)($req['action'] ?? ''); if (!is_dir('/etc/apache2')) fail('Apache não encontrado neste servidor. Instale o LAMP pelo lk (opção 1).'); switch ($action) { case 'list': { $sites = array_map('site_info', site_names()); out(['sites' => $sites, 'php' => php_versions(), 'mysql' => mysql_ok(), 'ip' => server_ip(), 'root' => is_root()]); } case 'size': { $i = site_info(site_name($req['name'] ?? '')); if (!$i['exists']) fail('Pasta do site não encontrada: ' . $i['root']); [$rc, $o] = run(['timeout', '40', 'du', '-sb', '--', $i['root']]); if (!preg_match('/^(\d+)/', $o, $m)) fail('Não foi possível calcular o tamanho' . ($rc === 124 ? ' (demorou demais)' : '')); out(['size' => (int)$m[1]]); } case 'logs': { $i = site_info(site_name($req['name'] ?? '')); $which = (string)($req['which'] ?? 'error'); $n = max(20, min(2000, (int)($req['lines'] ?? 200))); $q = trim((string)($req['q'] ?? '')); $avail = []; if ($i['errlog']) $avail['error'] = $i['errlog']; if (preg_match('/^\s*CustomLog\s+(\S+)/mi', (string)@file_get_contents(AV . '/' . $i['name'] . '.conf'), $m)) $avail['access'] = apache_log_path($m[1]); $lv = glob($i['root'] . '/storage/logs/laravel*.log') ?: []; usort($lv, fn($a, $b) => filemtime($b) <=> filemtime($a)); if ($lv) $avail['laravel'] = $lv[0]; elseif ($i['type'] === 'laravel') $avail['laravel'] = $i['root'] . '/storage/logs/laravel.log'; $f = $avail[$which] ?? null; if (!$f) fail('Este site não tem esse log.', ['avail' => array_keys($avail)]); if (!is_file($f)) out(['file' => $f, 'text' => '', 'missing' => true, 'avail' => array_keys($avail)]); if (!is_readable($f)) fail('Sem permissão para ler ' . $f . ' (o painel precisa de acesso root).', ['avail' => array_keys($avail)]); [$text, $total] = tail_file($f, $n, $q); clearstatcache(); out(['file' => $f, 'text' => $text ?? '', 'matches' => $total, 'size' => filesize($f), 'mtime' => filemtime($f), 'avail' => array_keys($avail)]); } case 'toggle': { need_root(); $n = site_name($req['name'] ?? ''); $on = !empty($req['on']); $ssl = is_file(AV . "/$n-le-ssl.conf"); if ($on) { run(['a2ensite', '-q', $n]); if ($ssl) run(['a2ensite', '-q', "$n-le-ssl"]); [$ok, $o] = apache_test(); if (!$ok) { run(['a2dissite', '-q', $n]); if ($ssl) run(['a2dissite', '-q', "$n-le-ssl"]); fail("Não foi possível ligar $n: a configuração do Apache tem erro.", ['detail' => $o]); } } else { run(['a2dissite', '-q', $n]); if ($ssl) run(['a2dissite', '-q', "$n-le-ssl"]); } apache_reload(); out(['site' => site_info($n)]); } case 'create': { need_root(); $d = norm_domain($req['domain'] ?? ''); if (!valid_domain($d)) fail("Domínio inválido: $d"); if (in_array($d, SKIP, true) || is_file(AV . "/$d.conf")) fail("O site $d já existe"); $type = (string)($req['type'] ?? 'php'); $www = !empty($req['www']); $root = WWW . "/$d"; $created = !file_exists($root); $mods = ['proxy', 'proxy_fcgi', 'setenvif']; if ($type === 'php' || $type === 'laravel') { $pv = (string)($req['php'] ?? ''); if (!in_array($pv, php_versions(), true)) fail('Versão do PHP inválida ou sem PHP-FPM: ' . $pv); $docroot = $type === 'php' ? "$root/public_html" : "$root/public"; if ($type === 'php') { @mkdir($docroot, 0755, true); if (!file_exists("$docroot/index.php") && !file_exists("$docroot/index.html")) { $t = htmlspecialchars($d); file_put_contents("$docroot/index.php", "\n$t\n\n

$t

Site no ar.

PHP
\n"); } } else { @mkdir($root, 0755, true); } $body = " DocumentRoot $docroot\n \n Options -Indexes +FollowSymLinks\n AllowOverride All\n Require all granted\n \n" . " \n SetHandler \"proxy:unix:/run/php/php$pv-fpm.sock|fcgi://localhost\"\n "; } elseif ($type === 'proxy') { $port = (int)($req['port'] ?? 0); if ($port < 1 || $port > 65535) fail('Porta inválida'); $mods = array_merge($mods, ['proxy_http', 'proxy_wstunnel', 'rewrite', 'headers']); @mkdir($root, 0755, true); $body = " ProxyPreserveHost On\n ProxyPass / http://127.0.0.1:$port/\n ProxyPassReverse / http://127.0.0.1:$port/\n" . " RewriteEngine On\n RewriteCond %{HTTP:Upgrade} =websocket [NC]\n RewriteRule /(.*) ws://127.0.0.1:$port/\$1 [P,L]"; } else { fail('Tipo de site inválido'); } run(array_merge(['a2enmod', '-q'], $mods)); $conf = "# Site $d - gerado por LK em " . date('Y-m-d H:i:s') . "\n\n ServerName $d\n" . ($www ? " ServerAlias www.$d\n" : '') . $body . "\n" . " ErrorLog \${APACHE_LOG_DIR}/{$d}_error.log\n CustomLog \${APACHE_LOG_DIR}/{$d}_access.log combined\n\n"; if (@file_put_contents(AV . "/$d.conf", $conf) === false) fail('Não foi possível gravar ' . AV . "/$d.conf"); run(['chown', '-R', 'www-data:www-data', $root]); run(['a2ensite', '-q', $d]); [$ok, $o] = apache_test(); if (!$ok) { run(['a2dissite', '-q', $d]); @unlink(AV . "/$d.conf"); if ($created) rm_tree($root); fail('A configuração do Apache ficou com erro e o site foi desfeito.', ['detail' => $o]); } apache_reload(); $db = null; $dbErr = null; if (!empty($req['db']) && $type !== 'proxy') { if (!mysql_ok()) { $dbErr = 'MySQL/MariaDB não encontrado ou sem acesso root pelo socket.'; } else { $name = substr(preg_replace('/[^a-z0-9]/', '_', $d), 0, 64); $user = substr($name, 0, 32); $pass = gen_pass(); $sql = "CREATE DATABASE IF NOT EXISTS `$name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n" . "CREATE USER IF NOT EXISTS '$user'@'localhost' IDENTIFIED BY '$pass';\n" . "ALTER USER '$user'@'localhost' IDENTIFIED BY '$pass';\n" . "GRANT ALL PRIVILEGES ON `$name`.* TO '$user'@'localhost';\nFLUSH PRIVILEGES;\n"; [$rc, $o] = run(['mysql', '--protocol=socket', '-uroot'], $sql); if ($rc !== 0) { $dbErr = 'Falha ao criar o banco: ' . $o; } else { $db = ['name' => $name, 'user' => $user, 'pass' => $pass, 'host' => 'localhost']; @mkdir('/etc/lk', 0700, true); @file_put_contents(LK_DBFILE, date('Y-m-d H:i:s') . " | db=$name | user=$user | pass=$pass | host=localhost\n", FILE_APPEND); @chmod(LK_DBFILE, 0600); } } } @file_put_contents('/var/log/lk.log', date('Y-m-d H:i:s') . " OK: site $d criado pelo LK Monitor ($type)\n", FILE_APPEND); $ip = server_ip(); out(['site' => site_info($d), 'hosts' => "$ip $d" . ($www ? " www.$d" : ''), 'ip' => $ip, 'db' => $db, 'db_error' => $dbErr]); } case 'remove': { need_root(); $n = site_name($req['name'] ?? ''); if ((string)($req['confirm'] ?? '') !== $n) fail('Confirmação não confere: digite o domínio exatamente.'); $info = site_info($n); run(['a2dissite', '-q', $n]); if (is_file(AV . "/$n-le-ssl.conf")) run(['a2dissite', '-q', "$n-le-ssl"]); $notes = []; if (is_executable('/usr/bin/certbot')) { [$rc, $o] = run(['certbot', 'certificates', '--cert-name', $n]); if ($rc === 0 && str_contains($o, "Certificate Name: $n")) { [$rc2] = run(['certbot', 'delete', '--cert-name', $n, '--non-interactive']); if ($rc2 === 0) $notes[] = 'Certificado SSL removido'; } } @unlink(AV . "/$n.conf"); @unlink(AV . "/$n-le-ssl.conf"); [$ok, $o] = apache_test(); if ($ok) run(['systemctl', 'reload', 'apache2']); else $notes[] = 'Atenção: a configuração do Apache tem erro em outro arquivo; o Apache não foi recarregado.'; $dir = WWW . "/$n"; if (!empty($req['files']) && valid_domain($n) && is_dir($dir) && !is_link($dir) && realpath($dir) === $dir) { rm_tree($dir); clearstatcache(); $notes[] = is_dir($dir) ? "Não foi possível apagar todos os arquivos de $dir" : "Arquivos apagados ($dir)"; } elseif (is_dir($dir)) { $notes[] = "Arquivos mantidos em $dir"; } @file_put_contents('/var/log/lk.log', date('Y-m-d H:i:s') . " OK: site $n removido pelo LK Monitor\n", FILE_APPEND); out(['notes' => $notes, 'root' => $info['root']]); } case 'deploy_info': { $i = site_info(site_name($req['name'] ?? '')); $r = $i['root']; $node = trim((string)shell_exec('PATH=' . JOB_PATH . ' command -v node 2>/dev/null')) !== ''; $run = is_root() ? job_running_for($i['name']) : null; out([ 'laravel' => is_file("$r/artisan"), 'root' => $r, 'php' => $i['php'], 'composer' => trim((string)shell_exec('PATH=' . JOB_PATH . ' command -v composer 2>/dev/null')) !== '', 'node' => $node, 'pkg' => is_file("$r/package.json"), 'hot' => file_exists("$r/public/hot"), 'vendor' => is_dir("$r/vendor"), 'running' => $run['id'] ?? null, ]); } case 'deploy': { need_root(); $i = site_info(site_name($req['name'] ?? '')); if (!is_file($i['root'] . '/artisan')) fail('Este site não é um projeto Laravel (artisan não encontrado em ' . $i['root'] . ').'); if (!str_starts_with($i['root'] . '/', WWW . '/') || realpath($i['root']) !== $i['root']) fail('Pasta do site fora de ' . WWW . ': ' . $i['root']); if ($run = job_running_for($i['name'])) fail('Já existe um deploy em andamento para este site.', ['running' => $run['id']]); $o = []; foreach (array_merge(DEPLOY_OPTS, ['nodev']) as $k) $o[$k] = !empty($req['opts'][$k]); if (!array_filter(array_intersect_key($o, array_flip(DEPLOY_OPTS)))) fail('Marque pelo menos um passo.'); if ($o['npm'] && !is_file($i['root'] . '/package.json')) $o['npm'] = false; $J = job_start('deploy', 'Deploy ' . $i['name'], ['site' => $i['name'], 'root' => $i['root'], 'php' => $i['php'], 'opts' => $o]); out(['job' => $J['id']]); } case 'backups': { $i = site_info(site_name($req['name'] ?? '')); $run = job_running_for($i['name']); $cfg = bk_cfg(); out(['backups' => array_map(fn($m) => array_diff_key($m, ['excluded' => 1]), bk_list($i['name'])), 'db' => site_db($i['root']), 'root' => $i['root'], 'running' => $run['id'] ?? null, 'running_type' => $run['type'] ?? null, 'auto' => ['enabled' => $cfg['enabled'], 'time' => $cfg['time'], 'keep' => $cfg['keep'], 'on' => in_array($i['name'], $cfg['sites'], true)]]); } case 'backup': { need_root(); $i = site_info(site_name($req['name'] ?? '')); if ($run = job_running_for($i['name'])) fail('Já existe uma tarefa em andamento para este site.', ['running' => $run['id']]); $J = job_start('backup', 'Backup ' . $i['name'], ['site' => $i['name'], 'root' => $i['root'], 'kind' => 'manual']); out(['job' => $J['id']]); } case 'backup_delete': { need_root(); $n = site_name($req['name'] ?? ''); try { bk_delete($n, (string)($req['file'] ?? '')); } catch (Throwable $e) { fail($e->getMessage()); } out(); } case 'restore': { need_root(); $i = site_info(site_name($req['name'] ?? '')); if ((string)($req['confirm'] ?? '') !== $i['name']) fail('Confirmação não confere: digite o domínio exatamente.'); try { $p = bk_file_of($i['name'], (string)($req['file'] ?? '')); } catch (Throwable $e) { fail($e->getMessage()); } if ($run = job_running_for($i['name'])) fail('Já existe uma tarefa em andamento para este site.', ['running' => $run['id']]); $J = job_start('restore', 'Restaurar ' . $i['name'], ['site' => $i['name'], 'root' => $i['root'], 'file_in' => $p, 'php' => $i['php']]); out(['job' => $J['id']]); } case 'backup_cfg': { $next = trim((string)shell_exec('systemctl show lkmonitor-backup.timer -p NextElapseUSecRealtime --value 2>/dev/null')); $active = trim((string)shell_exec('systemctl is-active lkmonitor-backup.timer 2>/dev/null')) === 'active'; out(['cfg' => bk_cfg(), 'sites' => site_names(), 'next' => $active && $next !== '' && $next !== 'n/a' ? (strtotime($next) ?: null) : null, 'timer' => $active]); } case 'backup_cfg_set': { need_root(); try { $c = bk_cfg_save((array)($req['cfg'] ?? [])); } catch (Throwable $e) { fail($e->getMessage()); } out(['cfg' => $c]); } default: fail('Ação desconhecida: ' . $action); } LKM_EOF_SITES cat > "$LIBDIR/db.php" <<'LKM_EOF_DB' true] + $d, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR); exit(0); } function fail(string $m, array $extra = []): never { echo json_encode(['ok' => false, 'error' => $m] + $extra, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } function run(array $argv, ?string $stdin = null): array { $p = proc_open($argv, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['redirect', 1]], $pipes); if (!is_resource($p)) return [127, 'falha ao executar ' . $argv[0]]; if ($stdin !== null) fwrite($pipes[0], $stdin); fclose($pipes[0]); $o = stream_get_contents($pipes[1]); fclose($pipes[1]); return [proc_close($p), trim((string)$o)]; } function gen_pass(int $n = 20): string { $a = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; $s = ''; for ($i = 0; $i < $n; $i++) $s .= $a[random_int(0, strlen($a) - 1)]; return $s; } /* ------------------------------------------------------------------ MySQL */ function mysql_socket(): ?string { foreach (['/run/mysqld/mysqld.sock', '/var/run/mysqld/mysqld.sock', '/tmp/mysql.sock', (string)ini_get('pdo_mysql.default_socket')] as $s) { if ($s !== '' && file_exists($s)) return $s; } return null; } function my(?string $db = null): PDO { static $c = []; $k = $db ?? ''; if (isset($c[$k])) return $c[$k]; if (!extension_loaded('pdo_mysql')) fail('A extensão MySQL do PHP não está instalada (php-mysql). Rode o instalador do LK Monitor de novo.'); $sock = mysql_socket(); if (!$sock) fail('MySQL/MariaDB não encontrado ou parado (socket não existe).'); try { $pdo = new PDO('mysql:unix_socket=' . $sock . ';charset=utf8mb4' . ($db !== null ? ';dbname=' . $db : ''), 'root', '', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5, PDO::MYSQL_ATTR_MULTI_STATEMENTS => false, PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_NUM, ]); } catch (Throwable $e) { fail('Não foi possível entrar no MySQL como root pelo socket: ' . $e->getMessage()); } // limite de tempo por consulta (MariaDB / MySQL) try { $pdo->exec('SET SESSION max_statement_time=30'); } catch (Throwable $e) { try { $pdo->exec('SET SESSION MAX_EXECUTION_TIME=30000'); } catch (Throwable $e2) { } } return $c[$k] = $pdo; } function q1(PDO $p, string $sql, array $a = []): array { $st = $p->prepare($sql); $st->execute($a); return $st->fetchAll(PDO::FETCH_ASSOC); } function my_dbs(): array { return array_values(array_diff(my()->query('SHOW DATABASES')->fetchAll(PDO::FETCH_COLUMN), SYS_DBS)); } function db_name($n): string { $n = (string)$n; if (!preg_match('/^[A-Za-z0-9_$-]{1,64}$/', $n) || in_array(strtolower($n), SYS_DBS, true)) fail('Nome de banco inválido'); if (!in_array($n, my_dbs(), true)) fail("Banco não encontrado: $n"); return $n; } function my_users(): array { $u = []; foreach (q1(my(), 'SELECT User, Host FROM mysql.user') as $r) { if (in_array($r['User'], SYS_USERS, true)) continue; $u[$r['User'] . '@' . $r['Host']] = ['user' => $r['User'], 'host' => $r['Host'], 'dbs' => []]; } foreach (q1(my(), 'SELECT User, Host, Db FROM mysql.db') as $r) { $k = $r['User'] . '@' . $r['Host']; if (isset($u[$k])) $u[$k]['dbs'][] = str_replace('\\_', '_', $r['Db']); } return array_values($u); } function qi(string $n, string $kind): string { return $kind === 'mysql' ? '`' . str_replace('`', '``', $n) . '`' : '"' . str_replace('"', '""', $n) . '"'; } /* ------------------------------------------------------------------ sites (.env) */ function env_read(string $f): array { $e = []; foreach (@file($f, FILE_IGNORE_NEW_LINES) ?: [] as $l) { if (preg_match('/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/', $l, $m)) $e[$m[1]] = trim($m[2], "\"'"); } return $e; } /** Sites com .env: qual conexão, banco e usuário cada um usa. */ function sites_env(): array { $out = []; foreach (glob(WWW . '/*/.env') ?: [] as $f) { $root = dirname($f); $e = env_read($f); $conn = $e['DB_CONNECTION'] ?? ''; $db = $e['DB_DATABASE'] ?? ''; if ($conn === 'sqlite') { $db = $db === '' ? "$root/database/database.sqlite" : (str_starts_with($db, '/') ? $db : "$root/$db"); } $out[] = ['site' => basename($root), 'root' => $root, 'conn' => $conn, 'db' => $db, 'user' => $e['DB_USERNAME'] ?? '']; } return $out; } /* ------------------------------------------------------------------ SQLite */ function is_sqlite(string $f): bool { $h = @fopen($f, 'rb'); if (!$h) return false; $s = (string)fread($h, 16); fclose($h); return $s === "SQLite format 3\0"; } function sqlite_files(): array { [, $o] = run(['timeout', '15', 'find', WWW, '-maxdepth', '6', '(', '-name', 'node_modules', '-o', '-name', 'vendor', '-o', '-name', '.git', ')', '-prune', '-o', '-type', 'f', '(', '-name', '*.sqlite', '-o', '-name', '*.sqlite3', '-o', '-name', '*.db', ')', '-size', '-4G', '-print']); $list = []; foreach (array_filter(explode("\n", $o)) as $f) { if (!str_starts_with($f, WWW . '/') || !is_sqlite($f)) continue; $list[] = $f; if (count($list) >= 200) break; } sort($list); return $list; } function sqlite_path($p): string { $p = (string)$p; $r = realpath($p); if ($r === false || !str_starts_with($r, WWW . '/') || !is_file($r) || !is_sqlite($r)) fail('Arquivo SQLite inválido (precisa estar dentro de ' . WWW . ').'); return $r; } /** Abre o SQLite como o dono do arquivo; leitura em modo somente leitura. */ function lite(string $f, bool $write): PDO { $st = stat($f); if (function_exists('posix_geteuid') && posix_geteuid() === 0 && $st['uid'] !== 0) { @posix_setegid($st['gid']); @posix_seteuid($st['uid']); } $opt = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_NUM]; if (!$write && defined('PDO::SQLITE_ATTR_OPEN_FLAGS')) $opt[PDO::SQLITE_ATTR_OPEN_FLAGS] = PDO::SQLITE_OPEN_READONLY; try { return new PDO('sqlite:' . $f, null, null, $opt); } catch (Throwable $e) { fail('Não foi possível abrir o SQLite: ' . $e->getMessage()); } } /* ------------------------------------------------------------------ fonte (banco aberto no visualizador) */ function src(array $req, bool $write = false): array { $s = $req['src'] ?? []; if (($s['type'] ?? '') === 'mysql') { $db = db_name($s['db'] ?? ''); return [my($db), 'mysql', $db]; } if (($s['type'] ?? '') === 'sqlite') { $f = sqlite_path($s['path'] ?? ''); return [lite($f, $write), 'sqlite', $f]; } fail('Banco não informado'); } function tables(PDO $p, string $kind, string $db): array { $out = []; if ($kind === 'mysql') { foreach (q1($p, 'SELECT TABLE_NAME n, TABLE_TYPE t, TABLE_ROWS r, DATA_LENGTH + INDEX_LENGTH s FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME', [$db]) as $r) { $view = $r['t'] === 'VIEW'; $rows = $r['r'] === null ? null : (int)$r['r']; $exact = false; if (!$view && ($rows === null || $rows < 50000)) { try { $rows = (int)$p->query('SELECT COUNT(*) FROM ' . qi($r['n'], $kind))->fetchColumn(); $exact = true; } catch (Throwable $e) { } } $out[] = ['name' => $r['n'], 'view' => $view, 'rows' => $rows, 'exact' => $exact, 'size' => $r['s'] === null ? null : (int)$r['s']]; } } else { foreach ($p->query("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name")->fetchAll() as [$n, $t]) { $rows = null; try { $rows = (int)$p->query('SELECT COUNT(*) FROM ' . qi($n, $kind))->fetchColumn(); } catch (Throwable $e) { } $out[] = ['name' => $n, 'view' => $t === 'view', 'rows' => $rows, 'exact' => true, 'size' => null]; } } return $out; } function table_name(PDO $p, string $kind, string $db, $t): string { $t = (string)$t; foreach (tables_light($p, $kind, $db) as $n) if ($n === $t) return $t; fail("Tabela não encontrada: $t"); } function tables_light(PDO $p, string $kind, string $db): array { return $kind === 'mysql' ? array_column(q1($p, 'SELECT TABLE_NAME n FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?', [$db]), 'n') : $p->query("SELECT name FROM sqlite_master WHERE type IN ('table','view')")->fetchAll(PDO::FETCH_COLUMN); } function columns(PDO $p, string $kind, string $db, string $t): array { if ($kind === 'mysql') { return array_map(fn($r) => ['name' => $r['n'], 'type' => $r['t'], 'null' => $r['nl'] === 'YES', 'key' => $r['k'], 'default' => $r['d'], 'extra' => $r['e']], q1($p, 'SELECT COLUMN_NAME n, COLUMN_TYPE t, IS_NULLABLE nl, COLUMN_KEY k, COLUMN_DEFAULT d, EXTRA e FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION', [$db, $t])); } $out = []; foreach ($p->query('PRAGMA table_info(' . qi($t, $kind) . ')')->fetchAll(PDO::FETCH_ASSOC) as $r) { $out[] = ['name' => $r['name'], 'type' => $r['type'], 'null' => !$r['notnull'], 'key' => $r['pk'] ? 'PRI' : '', 'default' => $r['dflt_value'], 'extra' => '']; } return $out; } /** Converte um valor para mostrar/exportar: binário vira marcador; texto longo é cortado (só na tela). */ function cell($v, int $cut) { if ($v === null || is_int($v) || is_float($v)) return $v; $v = (string)$v; if (!mb_check_encoding($v, 'UTF-8')) return $cut ? '[binário ' . strlen($v) . ' bytes]' : '0x' . bin2hex($v); if ($cut && mb_strlen($v) > $cut) return mb_substr($v, 0, $cut) . '…'; return $v; } function fetch_rows(PDOStatement $st, int $max, int $cut): array { $cols = []; for ($i = 0; $i < $st->columnCount(); $i++) $cols[] = (string)($st->getColumnMeta($i)['name'] ?? "col$i"); $rows = []; $bytes = 0; $more = false; while (($r = $st->fetch(PDO::FETCH_NUM)) !== false) { if (count($rows) >= $max || $bytes > MAX_BYTES) { $more = true; break; } $r = array_map(fn($v) => cell($v, $cut), $r); $bytes += strlen(json_encode($r, JSON_INVALID_UTF8_SUBSTITUTE) ?: ''); $rows[] = $r; } $st->closeCursor(); return [$cols, $rows, $more]; } /** Remove comentários e textos entre aspas, para analisar o comando com segurança. */ function sql_skeleton(string $sql): string { return (string)preg_replace(['/--[^\n]*|#[^\n]*|\/\*.*?\*\//s', "/'(?:[^'\\\\]|\\\\.|'')*'/s", '/"(?:[^"\\\\]|\\\\.|"")*"/s', '/`(?:[^`]|``)*`/s'], [' ', "''", '""', '``'], $sql); } function sql_check(string $sql, string $kind, bool $write): array { $sk = trim(sql_skeleton($sql)); $sk = rtrim($sk, "; \n\t\r"); if ($sk === '') fail('Digite uma consulta.'); if (str_contains($sk, ';')) fail('Execute um comando por vez (sem ";" no meio).'); $verb = strtoupper((string)preg_split('/[\s(]+/', $sk)[0]); $read = $kind === 'mysql' ? ['SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN', 'WITH'] : ['SELECT', 'PRAGMA', 'EXPLAIN', 'WITH']; $wr = ['INSERT', 'UPDATE', 'DELETE', 'REPLACE']; if (preg_match('/\bINTO\s+(OUTFILE|DUMPFILE)\b|\bLOAD_FILE\s*\(|\bload_extension\s*\(/i', $sk)) fail('Este comando não é permitido no painel.'); if ($verb === 'PRAGMA' && str_contains($sk, '=')) fail('PRAGMA que altera configuração não é permitido.'); if (in_array($verb, $read, true)) return [$verb, false]; if (in_array($verb, $wr, true)) { if (!$write) fail('Consulta em modo somente leitura. Ative "Permitir alterações" para rodar ' . $verb . '.', ['needs_write' => true]); return [$verb, true]; } fail('Comando não permitido: ' . $verb . '. Use SELECT, SHOW, DESCRIBE, EXPLAIN' . ($kind === 'sqlite' ? ', PRAGMA' : '') . ' e, com alterações liberadas, INSERT, UPDATE ou DELETE.'); } /* ------------------------------------------------------------------ ações */ $req = json_decode((string)stream_get_contents(STDIN), true); if (!is_array($req)) fail('Requisição inválida'); if (!function_exists('posix_geteuid') || posix_geteuid() !== 0) fail('A aba Bancos precisa de acesso root no painel.'); $action = (string)($req['action'] ?? ''); switch ($action) { case 'overview': { $env = sites_env(); $mysql = ['ok' => false, 'error' => null, 'version' => null]; $dbs = []; $users = []; if (!mysql_socket()) { $mysql['error'] = 'MySQL/MariaDB não encontrado ou parado.'; } elseif (!extension_loaded('pdo_mysql')) { $mysql['error'] = 'A extensão MySQL do PHP (php-mysql) não está instalada.'; } else { try { $mysql['version'] = (string)my()->query('SELECT VERSION()')->fetchColumn(); $mysql['ok'] = true; $users = my_users(); $stats = []; foreach (q1(my(), 'SELECT TABLE_SCHEMA d, COUNT(*) n, SUM(DATA_LENGTH + INDEX_LENGTH) s FROM information_schema.TABLES GROUP BY TABLE_SCHEMA') as $r) $stats[$r['d']] = $r; foreach (my_dbs() as $d) { $dbs[] = [ 'name' => $d, 'tables' => (int)($stats[$d]['n'] ?? 0), 'size' => (int)($stats[$d]['s'] ?? 0), 'users' => array_values(array_map(fn($u) => $u['user'], array_filter($users, fn($u) => in_array($d, $u['dbs'], true)))), 'only' => array_values(array_map(fn($u) => $u['user'], array_filter($users, fn($u) => $u['dbs'] === [$d] && $u['host'] === 'localhost'))), 'sites' => array_values(array_map(fn($e) => $e['site'], array_filter($env, fn($e) => $e['conn'] !== 'sqlite' && $e['db'] === $d))), ]; } } catch (Throwable $e) { $mysql['error'] = $e->getMessage(); } } $lite = []; foreach (sqlite_files() as $f) { $lite[] = ['path' => $f, 'size' => filesize($f), 'mtime' => filemtime($f), 'site' => explode('/', substr($f, strlen(WWW) + 1))[0], 'used' => (bool)array_filter($env, fn($e) => $e['conn'] === 'sqlite' && realpath($e['db']) === $f)]; } foreach ($users as &$u) $u['sites'] = array_values(array_map(fn($e) => $e['site'], array_filter($env, fn($e) => $e['user'] === $u['user'] && $e['conn'] !== 'sqlite'))); unset($u); out(['mysql' => $mysql, 'dbs' => $dbs, 'users' => $users, 'sqlite' => $lite]); } case 'create': { $name = (string)($req['name'] ?? ''); $user = (string)($req['user'] ?? '') ?: substr($name, 0, 32); if (!preg_match('/^[A-Za-z0-9_]{1,64}$/', $name) || in_array(strtolower($name), SYS_DBS, true)) fail('Nome do banco inválido: use letras, números e _ (até 64).'); if (!preg_match('/^[A-Za-z0-9_]{1,32}$/', $user) || in_array($user, SYS_USERS, true)) fail('Usuário inválido: use letras, números e _ (até 32).'); if (in_array($name, my()->query('SHOW DATABASES')->fetchAll(PDO::FETCH_COLUMN), true)) fail("Já existe um banco chamado $name."); if (array_filter(my_users(), fn($u) => $u['user'] === $user)) fail("O usuário $user já existe. Escolha outro nome (ou use Trocar senha)."); $pass = gen_pass(); $p = my(); try { $p->exec('CREATE DATABASE ' . qi($name, 'mysql') . ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'); $p->exec('CREATE USER ' . $p->quote($user) . "@'localhost' IDENTIFIED BY " . $p->quote($pass)); $p->exec('GRANT ALL PRIVILEGES ON ' . qi($name, 'mysql') . '.* TO ' . $p->quote($user) . "@'localhost'"); $p->exec('FLUSH PRIVILEGES'); } catch (Throwable $e) { fail('Falha ao criar: ' . $e->getMessage()); } @mkdir('/etc/lk', 0700, true); @file_put_contents(LK_DBFILE, date('Y-m-d H:i:s') . " | db=$name | user=$user | pass=$pass | host=localhost\n", FILE_APPEND); @chmod(LK_DBFILE, 0600); out(['db' => ['name' => $name, 'user' => $user, 'pass' => $pass, 'host' => 'localhost']]); } case 'drop': { $name = db_name($req['name'] ?? ''); if ((string)($req['confirm'] ?? '') !== $name) fail('Confirmação não confere: digite o nome do banco exatamente.'); if (!is_dir(DROP_BACKUPS)) mkdir(DROP_BACKUPS, 0700, true); @chmod(dirname(DROP_BACKUPS), 0700); $f = DROP_BACKUPS . '/' . $name . '-' . date('Ymd-His') . '.sql.gz'; [$rc, $o] = run(['/bin/bash', '-o', 'pipefail', '-c', 'timeout 1800 mysqldump --single-transaction --routines --triggers --events --databases ' . escapeshellarg($name) . ' | gzip > ' . escapeshellarg($f)]); if ($rc !== 0 || !is_file($f) || filesize($f) < 20) { @unlink($f); fail('O backup antes de apagar falhou; nada foi apagado.', ['detail' => $o]); } @chmod($f, 0600); $dropUsers = array_values(array_intersect((array)($req['users'] ?? []), array_map(fn($u) => $u['user'], array_filter(my_users(), fn($u) => $u['dbs'] === [$name] && $u['host'] === 'localhost')))); try { my()->exec('DROP DATABASE ' . qi($name, 'mysql')); foreach ($dropUsers as $u) my()->exec('DROP USER ' . my()->quote($u) . "@'localhost'"); my()->exec('FLUSH PRIVILEGES'); } catch (Throwable $e) { fail('Falha ao apagar: ' . $e->getMessage(), ['backup' => $f]); } out(['backup' => $f, 'size' => filesize($f), 'users' => $dropUsers]); } case 'password': { $user = (string)($req['user'] ?? ''); $match = array_values(array_filter(my_users(), fn($u) => $u['user'] === $user && $u['host'] === 'localhost')); if (!$match) fail("Usuário não encontrado: $user"); $pass = (string)($req['pass'] ?? ''); if ($pass === '') $pass = gen_pass(); elseif (strlen($pass) < 8 || strlen($pass) > 64 || preg_match('/[\s\'"\\\\`$]/', $pass)) fail('Senha inválida: use de 8 a 64 caracteres, sem espaços, aspas, barra invertida, crase ou $.'); try { my()->exec('ALTER USER ' . my()->quote($user) . "@'localhost' IDENTIFIED BY " . my()->quote($pass)); my()->exec('FLUSH PRIVILEGES'); } catch (Throwable $e) { fail('Falha ao trocar a senha: ' . $e->getMessage()); } $updated = []; $want = (array)($req['sites'] ?? []); foreach (sites_env() as $e) { if ($e['user'] !== $user || !in_array($e['site'], $want, true)) continue; $f = $e['root'] . '/.env'; $val = preg_match('/^[A-Za-z0-9_]+$/', $pass) ? $pass : '"' . $pass . '"'; $c = (string)file_get_contents($f); $c = preg_match('/^DB_PASSWORD=.*$/m', $c) ? preg_replace('/^DB_PASSWORD=.*$/m', 'DB_PASSWORD=' . str_replace(['\\', '$'], ['\\\\', '\\$'], $val), $c) : rtrim($c) . "\nDB_PASSWORD=$val\n"; $st = stat($f); if (file_put_contents($f, $c) === false) continue; @chown($f, $st['uid']); @chgrp($f, $st['gid']); @chmod($f, $st['mode'] & 07777); if (is_file($e['root'] . '/artisan')) run(['/usr/sbin/runuser', '-u', 'www-data', '--', 'timeout', '60', PHP_BINARY, $e['root'] . '/artisan', 'config:clear']); $updated[] = $e['site']; } out(['user' => $user, 'pass' => $pass, 'updated' => $updated]); } case 'tables': { [$p, $kind, $db] = src($req); out(['tables' => tables($p, $kind, $db)]); } case 'columns': { [$p, $kind, $db] = src($req); $t = table_name($p, $kind, $db, $req['table'] ?? ''); out(['columns' => columns($p, $kind, $db, $t)]); } case 'rows': { [$p, $kind, $db] = src($req); $t = table_name($p, $kind, $db, $req['table'] ?? ''); $cols = columns($p, $kind, $db, $t); $page = max(1, (int)($req['page'] ?? 1)); $q = trim((string)($req['q'] ?? '')); $where = ''; $args = []; if ($q !== '') { $parts = []; foreach ($cols as $i => $c) { $parts[] = 'CAST(' . qi($c['name'], $kind) . ' AS ' . ($kind === 'mysql' ? 'CHAR' : 'TEXT') . ") LIKE ?"; $args[] = '%' . $q . '%'; } $where = ' WHERE ' . implode(' OR ', $parts); } $order = ''; $sort = (string)($req['sort'] ?? ''); if ($sort !== '' && in_array($sort, array_column($cols, 'name'), true)) $order = ' ORDER BY ' . qi($sort, $kind) . (!empty($req['desc']) ? ' DESC' : ' ASC'); try { if ($kind === 'mysql') $p->exec('START TRANSACTION READ ONLY'); $st = $p->prepare('SELECT COUNT(*) FROM ' . qi($t, $kind) . $where); $st->execute($args); $total = (int)$st->fetchColumn(); $st = $p->prepare('SELECT * FROM ' . qi($t, $kind) . $where . $order . ' LIMIT ' . PAGE . ' OFFSET ' . (($page - 1) * PAGE)); $st->execute($args); [$names, $rows] = fetch_rows($st, PAGE, 500); if ($kind === 'mysql') $p->exec('ROLLBACK'); } catch (Throwable $e) { fail('Erro ao ler a tabela: ' . $e->getMessage()); } out(['columns' => $names, 'rows' => $rows, 'total' => $total, 'page' => $page, 'pages' => max(1, (int)ceil($total / PAGE)), 'per' => PAGE]); } case 'query': case 'export': { $sql = trim((string)($req['sql'] ?? '')); $table = (string)($req['table'] ?? ''); $wantWrite = $action === 'query' && !empty($req['write']); [$p, $kind, $db] = src($req, $wantWrite); if ($action === 'export' && $table !== '') $sql = 'SELECT * FROM ' . qi(table_name($p, $kind, $db, $table), $kind); if (strlen($sql) > 100000) fail('Consulta muito grande.'); [$verb, $isWrite] = sql_check($sql, $kind, $wantWrite); if ($action === 'export' && $isWrite) fail('Só é possível exportar o resultado de consultas.'); $t0 = microtime(true); try { if (!$isWrite) { if ($kind === 'mysql') $p->exec('START TRANSACTION READ ONLY'); $st = $p->query(rtrim($sql, "; \n\t\r")); [$cols, $rows, $more] = fetch_rows($st, $action === 'export' ? MAX_EXPORT : MAX_ROWS, $action === 'export' ? 0 : 2000); if ($kind === 'mysql') $p->exec('ROLLBACK'); out(['columns' => $cols, 'rows' => $rows, 'more' => $more, 'ms' => (int)round((microtime(true) - $t0) * 1000), 'verb' => $verb]); } $n = $p->exec(rtrim($sql, "; \n\t\r")); out(['affected' => (int)$n, 'ms' => (int)round((microtime(true) - $t0) * 1000), 'verb' => $verb, 'columns' => [], 'rows' => []]); } catch (Throwable $e) { $m = $e->getMessage(); if (stripos($m, 'max_statement_time') !== false || stripos($m, 'maximum statement execution time') !== false) $m = 'A consulta passou do limite de 30 segundos e foi cancelada.'; if (stripos($m, 'READ ONLY transaction') !== false || stripos($m, 'readonly database') !== false) $m = 'Consulta em modo somente leitura: este comando tenta alterar dados.'; fail($m); } } default: fail('Ação desconhecida: ' . $action); } LKM_EOF_DB cat > "$LIBDIR/jobs.inc.php" <<'LKM_EOF_JOBSINC' $id, 'type' => $type, 'title' => $title, 'state' => 'queued', 'created' => time()] + $data; job_save($J); touch(job_file($id, 'log')); @chgrp(job_file($id, 'log'), 'lkmonitor'); @chmod(job_file($id, 'log'), 0640); return $J; } /** Cria e inicia uma tarefa fora do processo do painel (unidade systemd lkm-job-). */ function job_start(string $type, string $title, array $data): array { $J = job_create($type, $title, $data); $id = $J['id']; $runner = [PHP_BINARY, '-d', 'memory_limit=512M', '-d', 'display_errors=stderr', __DIR__ . '/job.php', $id]; $env = getenv('LKM_DATA') ? ['--setenv=LKM_DATA=' . getenv('LKM_DATA')] : []; $p = proc_open(array_merge(['/usr/bin/systemd-run', '--unit=lkm-job-' . $id, '--collect', '--quiet', '--no-block', '--property=Nice=5'], $env, $runner), [0 => ['file', '/dev/null', 'r'], 1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); $rc = is_resource($p) ? proc_close($p) : 1; if ($rc !== 0) { // sem systemd: processo desacoplado exec('/usr/bin/setsid -f ' . implode(' ', array_map('escapeshellarg', $runner)) . ' >/dev/null 2>&1 JOB_PATH, 'LANG' => 'C.UTF-8', 'HOME' => '/root', 'TERM' => 'dumb', 'NO_COLOR' => '1', 'COLUMNS' => '80'] + ($s['env'] ?? []); $timeout = (string)($s['timeout'] ?? 1800); $cmd = ['/usr/bin/timeout', '-k', '10', $timeout, '/bin/bash', '-c', $s['cmd']]; $as = $s['as'] ?? 'root'; if ($as !== 'root') { $pairs = []; foreach ($env as $k => $v) $pairs[] = "$k=$v"; $cmd = array_merge(['/usr/sbin/runuser', '-u', $as, '--', '/usr/bin/env', '-i'], $pairs, $cmd); } $p = proc_open($cmd, [0 => ['file', '/dev/null', 'r'], 1 => ['file', $logf, 'a'], 2 => ['file', $logf, 'a']], $pipes, $s['cwd'] ?? '/', $env); if (!is_resource($p)) return 127; return proc_close($p); } /** Executa todos os passos de uma tarefa já criada, atualizando o estado e o log. */ function job_run(string $id): int { $J = json_decode((string)@file_get_contents(job_file($id, 'json')), true); if (!is_array($J) || ($J['state'] ?? '') !== 'queued') return 0; $logf = job_file($id, 'log'); $J['state'] = 'running'; $J['pid'] = getmypid(); $J['started'] = time(); $steps = job_steps($J); $J['steps'] = array_map(fn($s) => ['key' => $s['key'], 'label' => $s['label'], 'state' => 'pending'], $steps); job_save($J); $log = fopen($logf, 'ab'); $w = function (string $s) use ($log): void { fwrite($log, $s); fflush($log); }; $w('== ' . $J['title'] . ' · ' . date('d/m/Y H:i:s') . " ==\n"); $failed = null; foreach ($steps as $i => $s) { $J['steps'][$i]['state'] = 'running'; $J['steps'][$i]['started'] = time(); job_save($J); $w("\n▶ " . $s['label'] . "\n"); $t0 = microtime(true); $note = ''; try { if (isset($s['fn'])) { [$rc, $note] = ($s['fn'])($w, $J); } else { if (!empty($s['show'])) $w('$ ' . $s['show'] . "\n"); $rc = job_exec($s, $logf); if ($rc === 1 && !empty($s['ok1'])) $rc = 0; } } catch (Throwable $e) { $rc = 1; $note = $e->getMessage(); } if ($note !== '') $w($note . "\n"); $dt = round(microtime(true) - $t0, 1); $J['steps'][$i]['ended'] = time(); $J['steps'][$i]['rc'] = $rc; if ($rc === -1) { $J['steps'][$i]['state'] = 'skipped'; $w("○ ignorado\n"); continue; } if ($rc !== 0) { $J['steps'][$i]['state'] = 'error'; $w('✖ falhou' . ($rc === 124 ? ' (tempo esgotado)' : " (código $rc)") . " em {$dt}s\n"); $failed = $i; break; } $J['steps'][$i]['state'] = 'ok'; $w("✔ concluído em {$dt}s\n"); job_save($J); } foreach ($J['steps'] as $i => $st) if ($st['state'] === 'pending') $J['steps'][$i]['state'] = 'canceled'; $J['state'] = $failed === null ? 'ok' : 'error'; $J['failed'] = $failed === null ? null : $J['steps'][$failed]['label']; $J['ended'] = time(); job_finish($J); $w("\n" . ($failed === null ? '✔ Concluído com sucesso' : '✖ Parou em: ' . $J['failed']) . ' · ' . job_dur($J['ended'] - $J['started']) . "\n"); fclose($log); job_save($J); job_prune(); return $failed === null ? 0 : 1; } function job_prune(int $keep = 150): void { $files = glob(jobs_dir() . '/*.json') ?: []; sort($files); foreach (array_slice($files, 0, max(0, count($files) - $keep)) as $f) { $J = json_decode((string)@file_get_contents($f), true); if (is_array($J) && job_alive($J)) continue; @unlink($f); @unlink(substr($f, 0, -5) . '.log'); } } /* ------------------------------------------------------------------ tipos de tarefa */ function job_steps(array $J): array { return match ($J['type']) { 'deploy' => deploy_steps($J), 'backup' => backup_steps($J), 'restore' => restore_steps($J), 'app' => app_steps($J), default => [['key' => 'x', 'label' => 'Tarefa desconhecida', 'fn' => fn() => [1, 'Tipo de tarefa desconhecido: ' . $J['type']]]], }; } /** App Store: instalar / desinstalar um app do catálogo (apps.inc.php). */ function app_steps(array $J): array { require_once __DIR__ . '/apps.inc.php'; $a = app_find((string)($J['app'] ?? '')); if (!$a) return [['key' => 'x', 'label' => 'App desconhecido', 'fn' => fn() => [1, 'App não encontrado no catálogo: ' . ($J['app'] ?? '')]]]; return app_job_steps($a, ($J['op'] ?? '') === 'remove' ? 'remove' : 'install'); } /** Ao terminar: limpa pastas temporárias e anota o arquivo gerado. */ function job_finish(array &$J): void { if (in_array($J['type'], ['backup', 'restore'], true)) { foreach ([BK_DIR . '/.tmp-' . $J['id'], BK_DIR . '/.tmp-' . $J['id'] . '-s'] as $t) if (is_dir($t)) exec('rm -rf ' . escapeshellarg($t)); $made = BK_DIR . '/' . $J['site'] . '/.made-' . $J['id']; if (is_file($made)) { $J['file'] = trim((string)file_get_contents($made)); @unlink($made); } } } /** Opções conhecidas do deploy, na ordem em que rodam. */ const DEPLOY_OPTS = ['hot', 'perms', 'composer', 'migrate', 'optimize', 'storage', 'npm']; function php_for(?string $v): string { return $v && is_executable("/usr/bin/php$v") ? "/usr/bin/php$v" : PHP_BINARY; } function www_step(string $key, string $label, string $root, string $cmd, array $env = []): array { return ['key' => $key, 'label' => $label, 'as' => 'www-data', 'cwd' => $root, 'cmd' => $cmd, 'show' => $cmd, 'timeout' => 1800, 'env' => $env + ['HOME' => JOB_CACHE . '/npm']]; } function composer_bin(): string { return trim((string)shell_exec('PATH=' . JOB_PATH . ' command -v composer 2>/dev/null')); } function job_caches(): void { foreach (['composer', 'npm'] as $c) { if (!is_dir(JOB_CACHE . "/$c")) @mkdir(JOB_CACHE . "/$c", 0750, true); @chown(JOB_CACHE . "/$c", 'www-data'); @chgrp(JOB_CACHE . "/$c", 'www-data'); } } function composer_step(string $root, string $php, bool $nodev): array { $composer = composer_bin(); if ($composer === '') return ['key' => 'composer', 'label' => 'composer install', 'fn' => fn() => [1, 'Composer não está instalado. Instale pelo lk (opção 16).']]; return www_step('composer', 'composer install' . ($nodev ? ' --no-dev' : ''), $root, "$php " . escapeshellarg($composer) . ' install --no-interaction --prefer-dist --optimize-autoloader' . ($nodev ? ' --no-dev' : ''), ['COMPOSER_HOME' => JOB_CACHE . '/composer', 'COMPOSER_CACHE_DIR' => JOB_CACHE . '/composer/cache', 'HOME' => JOB_CACHE . '/composer']); } function deploy_steps(array $J): array { $root = $J['root']; $o = $J['opts']; $php = php_for($J['php'] ?? null); $q = 'escapeshellarg'; job_caches(); $s = []; if (!empty($o['hot'])) { $s[] = ['key' => 'hot', 'label' => 'Remover public/hot', 'fn' => function () use ($root) { $f = "$root/public/hot"; if (!file_exists($f) && !is_link($f)) return [-1, 'public/hot não existe']; return @unlink($f) ? [0, 'public/hot removido'] : [1, 'não foi possível apagar public/hot']; }]; } if (!empty($o['perms'])) { $dirs = array_values(array_filter(['storage', 'bootstrap/cache', 'database'], fn($d) => is_dir("$root/$d"))); $cmd = 'chown -R www-data:www-data ' . $q($root) . ($dirs ? ' && chmod -R ug+rwX ' . implode(' ', array_map($q, $dirs)) : ''); $s[] = ['key' => 'perms', 'label' => 'Corrigir dono (www-data) e permissões', 'cwd' => $root, 'cmd' => $cmd, 'show' => $cmd, 'timeout' => 600]; } if (!empty($o['composer'])) $s[] = composer_step($root, $php, !empty($o['nodev'])); if (!empty($o['migrate'])) $s[] = www_step('migrate', 'php artisan migrate --force', $root, "$php artisan migrate --force --no-interaction"); if (!empty($o['optimize'])) $s[] = www_step('optimize', 'php artisan optimize:clear', $root, "$php artisan optimize:clear"); if (!empty($o['storage'])) { $s[] = is_link("$root/public/storage") || file_exists("$root/public/storage") ? ['key' => 'storage', 'label' => 'php artisan storage:link', 'fn' => fn() => [-1, 'public/storage já existe']] : www_step('storage', 'php artisan storage:link', $root, "$php artisan storage:link"); } if (!empty($o['npm'])) { $inst = is_file("$root/package-lock.json") ? 'npm ci' : 'npm install'; $s[] = www_step('npm', $inst . ' && npm run build', $root, "$inst --no-audit --no-fund && npm run build", ['npm_config_cache' => JOB_CACHE . '/npm/cache']); } return $s; } /* ------------------------------------------------------------------ sites e bancos (lado root) */ function site_valid(string $n): bool { return (bool)preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/', $n) && !in_array($n, ['000-default', 'default-ssl', 'lkmonitor'], true) && !str_ends_with($n, '-le-ssl') && is_file(SITES_AV . "/$n.conf"); } /** Pasta do site, pelo mesmo critério da aba Sites (DocumentRoot sem public/ ou public_html/). */ function site_root_of(string $n): string { $c = (string)@file_get_contents(SITES_AV . "/$n.conf"); $doc = preg_match('/^\s*DocumentRoot\s+"?([^"\s]+)/mi', $c, $m) ? $m[1] : ''; if (preg_match('#/(public_html|public)/?$#', $doc)) $doc = dirname(rtrim($doc, '/')); if ($doc === '' || $doc === '/var/www') $doc = "/var/www/$n"; return rtrim($doc, '/'); } function env_parse(string $f): array { $e = []; foreach (@file($f, FILE_IGNORE_NEW_LINES) ?: [] as $l) { if (preg_match('/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/', $l, $m)) $e[$m[1]] = trim($m[2], "\"'"); } return $e; } /** Banco usado pelo site, conforme o .env (MySQL/MariaDB ou SQLite). */ function site_db(string $root): array { $e = env_parse("$root/.env"); $conn = strtolower($e['DB_CONNECTION'] ?? ''); if (in_array($conn, ['mysql', 'mariadb'], true) && preg_match('/^[A-Za-z0-9_$-]{1,64}$/', $e['DB_DATABASE'] ?? '')) { return ['type' => 'mysql', 'name' => $e['DB_DATABASE']]; } if ($conn === 'sqlite') { $p = $e['DB_DATABASE'] ?? ''; $p = $p === '' ? "$root/database/database.sqlite" : (str_starts_with($p, '/') ? $p : "$root/$p"); if (is_file($p)) return ['type' => 'sqlite', 'path' => realpath($p)]; } return ['type' => null]; } function lkm_version(): string { return preg_match("/const LKM_VERSION = '([^']+)'/", (string)@file_get_contents('/var/www/lkmonitor/index.php'), $m) ? $m[1] : '?'; } /* ------------------------------------------------------------------ backup */ function bk_site_dir(string $site): string { $d = BK_DIR . "/$site"; if (!is_dir($d)) mkdir($d, 0700, true); @chmod(BK_DIR, 0700); return $d; } /** Copia o banco para $tmp/db (MySQL: mysqldump | gzip; SQLite: .backup consistente, como o dono do arquivo). */ function bk_dump_db(array $db, string $tmp, string $logf): array { @mkdir("$tmp/db", 0700, true); if ($db['type'] === 'mysql') { $n = $db['name']; $cmd = 'set -o pipefail; mysqldump --single-transaction --quick --routines --triggers --events ' . escapeshellarg($n) . ' | gzip > ' . escapeshellarg("$tmp/db/$n.sql.gz"); $rc = job_exec(['cmd' => $cmd, 'timeout' => 3600], $logf); return [$rc, $rc === 0 ? "MySQL: banco $n copiado (" . bk_size(filesize("$tmp/db/$n.sql.gz")) . ')' : "falha no mysqldump do banco $n"]; } if ($db['type'] === 'sqlite') { $src = $db['path']; $st = stat($src); $owner = function_exists('posix_getpwuid') ? (posix_getpwuid($st['uid'])['name'] ?? 'root') : 'root'; $work = trim((string)shell_exec('mktemp -d /tmp/lkm-sqlite.XXXXXX')); @chown($work, $st['uid']); $dst = "$work/" . basename($src); if (is_executable('/usr/bin/sqlite3')) { $rc = job_exec(['cmd' => 'sqlite3 ' . escapeshellarg($src) . ' ' . escapeshellarg('.backup ' . "'" . str_replace("'", "''", $dst) . "'"), 'as' => $owner, 'timeout' => 1800], $logf); } else { $rc = copy($src, $dst) ? 0 : 1; } if ($rc === 0) rename($dst, "$tmp/db/" . basename($src)); exec('rm -rf ' . escapeshellarg($work)); return [$rc, $rc === 0 ? 'SQLite: ' . $src . ' copiado com .backup (' . bk_size(filesize("$tmp/db/" . basename($src))) . ')' : 'falha ao copiar o SQLite ' . $src]; } return [-1, 'nenhum banco no .env do site']; } function bk_size(int|false $b): string { $b = (int)$b; $u = ['B', 'KB', 'MB', 'GB', 'TB']; $i = 0; $x = (float)$b; while ($x >= 1024 && $i < 4) { $x /= 1024; $i++; } return ($i ? number_format($x, $x >= 100 ? 0 : 1, ',', '') : (string)$b) . ' ' . $u[$i]; } /** Gera o .tar.gz: meta.json + db/ + files/ (pasta do site sem vendor, node_modules, caches e logs). */ function bk_archive(string $site, string $root, array $db, string $tmp, string $kind, string $logf): array { $dir = bk_site_dir($site); $stamp = date('Ymd-His'); while (file_exists("$dir/$stamp.tar.gz")) { sleep(1); $stamp = date('Ymd-His'); } $out = "$dir/$stamp.tar.gz"; $meta = ['site' => $site, 'created' => time(), 'kind' => $kind, 'root' => $root, 'db' => $db, 'version' => lkm_version(), 'format' => 1, 'host' => gethostname(), 'excluded' => BK_EXCLUDE]; file_put_contents("$tmp/meta.json", json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); $base = basename($root); $ex = []; foreach (BK_EXCLUDE as $x) $ex[] = '--exclude=' . escapeshellarg("$base/$x"); if (($db['type'] ?? null) === 'sqlite' && str_starts_with($db['path'], "$root/")) { $rel = $base . substr($db['path'], strlen($root)); foreach (['', '-wal', '-shm', '-journal'] as $suf) $ex[] = '--exclude=' . escapeshellarg($rel . $suf); } $parts = '-C ' . escapeshellarg($tmp) . ' meta.json' . (is_dir("$tmp/db") ? ' db' : ''); if (is_dir($root)) $parts .= ' ' . implode(' ', $ex) . ' -C ' . escapeshellarg(dirname($root)) . " --transform 's,^" . str_replace([',', "'"], ['\\,', ''], preg_quote($base)) . ",files,S' " . escapeshellarg($base); $cmd = 'tar --warning=no-file-changed -czf ' . escapeshellarg($out) . ' ' . $parts; $rc = job_exec(['cmd' => $cmd, 'show' => 'tar -czf ' . $out, 'timeout' => 7200], $logf); if ($rc > 1 || !is_file($out)) { @unlink($out); return [$rc ?: 1, 'falha ao gerar o arquivo', null]; } @chmod($out, 0600); $meta['file'] = basename($out); $meta['size'] = filesize($out); file_put_contents("$dir/$stamp.json", json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); @chmod("$dir/$stamp.json", 0600); return [0, 'Arquivo: ' . $out . ' (' . bk_size($meta['size']) . ')', $out]; } function backup_steps(array $J): array { $site = $J['site']; $root = $J['root']; $db = site_db($root); $tmp = BK_DIR . '/.tmp-' . $J['id']; @mkdir($tmp, 0700, true); @chmod(BK_DIR, 0700); $logf = job_file($J['id'], 'log'); $kind = $J['kind'] ?? 'manual'; return [ ['key' => 'db', 'label' => 'Copiar o banco de dados', 'fn' => fn() => array_slice(bk_dump_db($db, $tmp, $logf), 0, 2)], ['key' => 'files', 'label' => 'Compactar os arquivos do site', 'fn' => function () use ($site, $root, $db, $tmp, $kind, $logf, $J) { [$rc, $note, $out] = bk_archive($site, $root, $db, $tmp, $kind, $logf); if ($out) file_put_contents(BK_DIR . "/$site/.made-" . $J['id'], $out); return [$rc, $note]; }], ]; } /** Lista os backups de um site (mais novo primeiro). */ function bk_list(string $site): array { $out = []; foreach (glob(BK_DIR . "/$site/*.json") ?: [] as $f) { $m = json_decode((string)file_get_contents($f), true); if (!is_array($m) || empty($m['file']) || !is_file(BK_DIR . "/$site/" . $m['file'])) continue; $m['path'] = BK_DIR . "/$site/" . $m['file']; $out[] = $m; } usort($out, fn($a, $b) => strcmp($b['file'], $a['file'])); return $out; } function bk_file_of(string $site, string $file): string { if (!preg_match('/^\d{8}-\d{6}\.tar\.gz$/', $file)) throw new RuntimeException('Arquivo de backup inválido'); $p = BK_DIR . "/$site/$file"; if (!is_file($p)) throw new RuntimeException('Backup não encontrado: ' . $file); return $p; } function bk_delete(string $site, string $file): void { $p = bk_file_of($site, $file); @unlink($p); @unlink(substr($p, 0, -7) . '.json'); } /** Mantém só os últimos $keep backups automáticos do site. */ function bk_prune(string $site, int $keep): array { $auto = array_values(array_filter(bk_list($site), fn($m) => ($m['kind'] ?? '') === 'auto')); $gone = []; foreach (array_slice($auto, max(1, $keep)) as $m) { bk_delete($site, $m['file']); $gone[] = $m['file']; } return $gone; } /* ------------------------------------------------------------------ restauração */ function restore_steps(array $J): array { $site = $J['site']; $root = $J['root']; $file = $J['file_in']; $php = php_for($J['php'] ?? null); $tmp = BK_DIR . '/.tmp-' . $J['id']; $logf = job_file($J['id'], 'log'); $q = 'escapeshellarg'; job_caches(); return [ ['key' => 'safety', 'label' => 'Backup de segurança do estado atual', 'fn' => function ($w) use ($site, $root, $J, $logf) { $t = BK_DIR . '/.tmp-' . $J['id'] . '-s'; @mkdir($t, 0700, true); $db = site_db($root); [$rc, $note] = bk_dump_db($db, $t, $logf); if ($rc > 0) return [$rc, $note]; $w($note . "\n"); [$rc, $note] = bk_archive($site, $root, $db, $t, 'seguranca', $logf); return [$rc, $note]; }], ['key' => 'extract', 'label' => 'Abrir o backup', 'fn' => function ($w) use ($file, $tmp, $logf) { @mkdir($tmp, 0700, true); $rc = job_exec(['cmd' => 'tar -xzf ' . escapeshellarg($file) . ' -C ' . escapeshellarg($tmp)], $logf); if ($rc !== 0) return [$rc, 'falha ao abrir ' . $file]; $m = json_decode((string)@file_get_contents("$tmp/meta.json"), true); if (!is_array($m)) return [1, 'meta.json ausente: este arquivo não parece um backup do LK Monitor']; return [0, 'Backup de ' . date('d/m/Y H:i', (int)$m['created']) . ' (' . ($m['site'] ?? '?') . ', versão ' . ($m['version'] ?? '?') . ')']; }], ['key' => 'files', 'label' => 'Restaurar os arquivos', 'fn' => function ($w) use ($root, $tmp, $logf, $q) { if (!is_dir("$tmp/files")) return [-1, 'o backup não tem arquivos']; if (!is_dir($root)) mkdir($root, 0755, true); $ex = ''; foreach (BK_EXCLUDE as $x) $ex .= ' --exclude=' . $q("/$x/"); $db = site_db($root); if (($db['type'] ?? null) === 'sqlite' && str_starts_with($db['path'], "$root/")) { foreach (['', '-wal', '-shm', '-journal'] as $suf) $ex .= ' --exclude=' . $q(substr($db['path'], strlen($root)) . $suf); } $cmd = 'rsync -a --delete' . $ex . ' ' . $q("$tmp/files/") . ' ' . $q("$root/"); $w('$ ' . $cmd . "\n"); return [job_exec(['cmd' => $cmd, 'timeout' => 7200], $logf), 'Arquivos restaurados em ' . $root . ' (vendor, node_modules, caches e logs foram mantidos)']; }], ['key' => 'db', 'label' => 'Restaurar o banco de dados', 'fn' => function ($w) use ($root, $tmp, $logf, $q) { $m = json_decode((string)@file_get_contents("$tmp/meta.json"), true); $db = $m['db'] ?? ['type' => null]; if (($db['type'] ?? null) === 'mysql') { $n = $db['name']; if (!preg_match('/^[A-Za-z0-9_$-]{1,64}$/', $n)) return [1, 'nome de banco inválido no backup']; $dump = "$tmp/db/$n.sql.gz"; if (!is_file($dump)) return [1, 'arquivo do banco ausente no backup']; $sql = "DROP DATABASE IF EXISTS `$n`; CREATE DATABASE `$n` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"; $cmd = 'set -o pipefail; mysql -e ' . $q($sql) . ' && gunzip -c ' . $q($dump) . ' | mysql ' . $q($n); $rc = job_exec(['cmd' => $cmd, 'timeout' => 7200], $logf); return [$rc, $rc === 0 ? "MySQL: banco $n restaurado" : "falha ao restaurar o banco $n"]; } if (($db['type'] ?? null) === 'sqlite') { $src = "$tmp/db/" . basename($db['path']); if (!is_file($src)) return [1, 'arquivo SQLite ausente no backup']; $cur = site_db($root); $dst = ($cur['type'] ?? null) === 'sqlite' ? $cur['path'] : $db['path']; if (!str_starts_with($dst, '/var/www/')) return [1, 'destino do SQLite fora de /var/www: ' . $dst]; $st = is_file($dst) ? stat($dst) : stat(dirname($dst)); foreach (['-wal', '-shm', '-journal'] as $suf) @unlink($dst . $suf); if (!copy($src, $dst)) return [1, 'falha ao copiar para ' . $dst]; @chown($dst, $st['uid']); @chgrp($dst, $st['gid']); @chmod($dst, 0664); return [0, 'SQLite restaurado em ' . $dst]; } return [-1, 'o backup não tem banco de dados']; }], is_file("$root/composer.json") || is_file("$tmp/files/composer.json") ? ['key' => 'composer', 'label' => 'composer install (se faltar vendor)', 'fn' => function ($w) use ($root, $php, $logf) { if (is_dir("$root/vendor")) return [-1, 'a pasta vendor já existe']; if (!is_file("$root/composer.json")) return [-1, 'sem composer.json']; $s = composer_step($root, $php, false); if (isset($s['fn'])) return ($s['fn'])(); $w('$ ' . $s['show'] . "\n"); return [job_exec($s, $logf), '']; }] : ['key' => 'composer', 'label' => 'composer install (se faltar vendor)', 'fn' => fn() => [-1, 'site sem composer.json']], ['key' => 'cache', 'label' => 'Limpar caches do Laravel', 'fn' => function ($w) use ($root, $php, $logf) { if (!is_file("$root/artisan")) return [-1, 'não é um projeto Laravel']; $s = www_step('cache', 'optimize:clear', $root, "$php artisan optimize:clear"); $w('$ ' . $s['show'] . "\n"); return [job_exec($s, $logf), '']; }], ]; } /* ------------------------------------------------------------------ backup automático */ function bk_cfg_file(): string { return (getenv('LKM_DATA') ?: '/var/lib/lkmonitor') . '/backup.json'; } function bk_cfg(): array { $c = json_decode((string)@file_get_contents(bk_cfg_file()), true); $c = is_array($c) ? $c : []; return [ 'enabled' => (bool)($c['enabled'] ?? true), 'time' => preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string)($c['time'] ?? '')) ? $c['time'] : '03:00', 'keep' => max(1, min(365, (int)($c['keep'] ?? 7))), 'sites' => array_values(array_filter((array)($c['sites'] ?? []), 'is_string')), ]; } function bk_cfg_save(array $c): array { $c = ['enabled' => !empty($c['enabled']), 'time' => (string)($c['time'] ?? '03:00'), 'keep' => (int)($c['keep'] ?? 7), 'sites' => array_values(array_unique(array_filter((array)($c['sites'] ?? []), fn($s) => is_string($s) && site_valid($s))))]; if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $c['time'])) throw new RuntimeException('Horário inválido (use HH:MM)'); if ($c['keep'] < 1 || $c['keep'] > 365) throw new RuntimeException('Quantidade inválida (1 a 365)'); $f = bk_cfg_file(); file_put_contents($f, json_encode($c, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); @chgrp($f, 'lkmonitor'); @chmod($f, 0640); bk_timer_write($c); return $c; } /** Grava o lkmonitor-backup.timer com o horário configurado. */ function bk_timer_write(array $c): void { $t = "[Unit]\nDescription=LK Monitor - backup automático diário\n\n[Timer]\nOnCalendar=*-*-* {$c['time']}:00\nRandomizedDelaySec=60\n\n[Install]\nWantedBy=timers.target\n"; file_put_contents('/etc/systemd/system/lkmonitor-backup.timer', $t); exec('systemctl daemon-reload 2>&1'); exec('systemctl ' . ($c['enabled'] ? 'enable --now' : 'disable --now') . ' lkmonitor-backup.timer 2>&1'); if ($c['enabled']) exec('systemctl restart lkmonitor-backup.timer 2>&1'); } /** Rodada do timer: um backup por site escolhido, depois apaga os automáticos mais antigos. */ function bk_auto(): int { $c = bk_cfg(); if (!$c['enabled'] || !$c['sites']) return 0; $fail = 0; foreach ($c['sites'] as $site) { if (!site_valid($site)) continue; if (job_running_for($site)) { $fail++; continue; } $J = job_create('backup', 'Backup automático ' . $site, ['site' => $site, 'root' => site_root_of($site), 'kind' => 'auto']); if (job_run($J['id']) !== 0) $fail++; bk_prune($site, $c['keep']); } return $fail ? 1 : 0; } LKM_EOF_JOBSINC cat > "$LIBDIR/job.php" <<'LKM_EOF_JOB' executa a tarefa (chamado via systemd-run pelos helpers) * php job.php --auto backup automático (lkmonitor-backup.timer) * php job.php --backup |all backup agora, mostrando o andamento (lk backup) * php job.php --restore restaura um backup (lk restore) * php job.php --list [site] lista os backups (lk backups) */ declare(strict_types=1); if (PHP_SAPI !== 'cli') { http_response_code(404); exit; } error_reporting(E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED); ini_set('display_errors', 'stderr'); umask(027); set_time_limit(0); require __DIR__ . '/jobs.inc.php'; $a = (string)($argv[1] ?? ''); /** Inicia a tarefa e mostra o log até terminar (para o terminal). */ function follow(array $J): int { $logf = job_file($J['id'], 'log'); $pos = 0; while (true) { clearstatcache(); $size = (int)@filesize($logf); if ($size > $pos) { $h = fopen($logf, 'rb'); fseek($h, $pos); echo fread($h, $size - $pos); fclose($h); $pos = $size; } $S = json_decode((string)@file_get_contents(job_file($J['id'], 'json')), true); if (is_array($S) && in_array($S['state'] ?? '', ['ok', 'error'], true)) { clearstatcache(); if ((int)@filesize($logf) > $pos) continue; if (!empty($S['file'])) echo "Arquivo: {$S['file']}\n"; return $S['state'] === 'ok' ? 0 : 1; } if (is_array($S) && !job_alive($S) && ($S['state'] ?? '') !== 'queued') { echo "\nA tarefa parou sem terminar.\n"; return 1; } usleep(400000); } } function need_site(string $s): string { if (!site_valid($s)) { fwrite(STDERR, "Site não encontrado: $s (veja: lk sites)\n"); exit(1); } return $s; } if (function_exists('posix_geteuid') && posix_geteuid() !== 0) { fwrite(STDERR, "Execute como root.\n"); exit(1); } switch ($a) { case '--auto': exit(bk_auto()); case '--backup': { $t = (string)($argv[2] ?? ''); if ($t === '') { fwrite(STDERR, "Uso: lk backup |all\n"); exit(1); } $sites = $t === 'all' ? array_values(array_filter(array_map(fn($f) => basename($f, '.conf'), glob(SITES_AV . '/*.conf') ?: []), 'site_valid')) : [need_site($t)]; if (!$sites) { echo "Nenhum site para copiar.\n"; exit(0); } $rc = 0; foreach ($sites as $s) { if (job_running_for($s)) { echo "$s: já existe uma tarefa em andamento, pulando.\n"; $rc = 1; continue; } $rc |= follow(job_start('backup', 'Backup ' . $s, ['site' => $s, 'root' => site_root_of($s), 'kind' => 'manual'])); echo "\n"; } exit($rc); } case '--restore': { $s = need_site((string)($argv[2] ?? '')); $f = basename((string)($argv[3] ?? '')); try { $p = bk_file_of($s, $f); } catch (Throwable $e) { fwrite(STDERR, $e->getMessage() . " (veja: lk backups $s)\n"); exit(1); } if (job_running_for($s)) { fwrite(STDERR, "Já existe uma tarefa em andamento para $s.\n"); exit(1); } $php = preg_match('#php(\d+\.\d+)-fpm\.sock#', (string)@file_get_contents(SITES_AV . "/$s.conf"), $m) ? $m[1] : null; exit(follow(job_start('restore', 'Restaurar ' . $s, ['site' => $s, 'root' => site_root_of($s), 'file_in' => $p, 'php' => $php]))); } case '--list': { $only = (string)($argv[2] ?? ''); $sites = $only !== '' ? [need_site($only)] : array_map('basename', glob(BK_DIR . '/*', GLOB_ONLYDIR) ?: []); $n = 0; $kinds = ['manual' => 'manual', 'auto' => 'automático', 'seguranca' => 'antes de restaurar']; foreach ($sites as $s) { $l = bk_list($s); if (!$l) continue; echo "\n $s\n"; foreach ($l as $m) { $k = $kinds[$m['kind'] ?? ''] ?? '-'; printf(" %-22s %-10s %s %s\n", $m['file'], bk_size($m['size'] ?? 0), $k . str_repeat(' ', max(0, 20 - mb_strlen($k))),($m['db']['type'] ?? null) ? 'banco ' . ($m['db']['name'] ?? basename($m['db']['path'] ?? '')) : 'sem banco'); $n++; } } echo $n ? "\n Pasta: " . BK_DIR . "//\n Restaurar: lk restore \n" : "Nenhum backup ainda. Crie com: lk backup \n"; exit(0); } } if (!job_valid_id($a) || !is_file(job_file($a, 'json'))) { fwrite(STDERR, "tarefa inválida\n"); exit(2); } exit(job_run($a)); LKM_EOF_JOB cat > "$LIBDIR/alerts.inc.php" <<'LKM_EOF_ALERTS' 30, 'rules' => [ 'temp' => ['on' => true, 'limit' => 80, 'minutes' => 3], 'disk' => ['on' => true, 'limit' => 90], 'ram' => ['on' => false, 'limit' => 90, 'minutes' => 5], 'service' => ['on' => true], 'site' => ['on' => true], 'ssh' => ['on' => true, 'limit' => 20], ], 'telegram' => ['on' => false, 'token' => '', 'chat' => ''], // mode: '' (automático), 'gowa' (WhatsApp deste servidor, via gowa.json) ou 'webhook' (gateway próprio) 'whatsapp' => ['on' => false, 'mode' => '', 'numbers' => '', 'url' => '', 'method' => 'POST', 'headers' => '', 'body' => '{"phone": "{phone}", "message": "{message}"}', 'phone' => ''], ]; const AL_HIST_MAX = 100; function al_dir(): string { return defined('LKM_DATA') ? LKM_DATA : (getenv('LKM_DATA') ?: '/var/lib/lkmonitor'); } function al_read(string $name, array $def = []): array { $d = json_decode((string)@file_get_contents(al_dir() . "/$name"), true); return is_array($d) ? $d : $def; } function al_write(string $name, array $d): void { $f = al_dir() . "/$name"; $tmp = $f . '.' . getmypid() . '.tmp'; if (@file_put_contents($tmp, json_encode($d, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) === false) return; @chmod($tmp, 0600); @rename($tmp, $f); } /** Configuração atual (com os padrões para o que faltar). */ function al_settings(): array { $s = al_read('settings.json'); $out = AL_DEFAULTS; $out['repeat'] = (int)($s['repeat'] ?? $out['repeat']); foreach (AL_DEFAULTS['rules'] as $k => $r) $out['rules'][$k] = array_merge($r, array_intersect_key((array)($s['rules'][$k] ?? []), $r)); foreach (['telegram', 'whatsapp'] as $c) $out[$c] = array_merge(AL_DEFAULTS[$c], array_intersect_key((array)($s[$c] ?? []), AL_DEFAULTS[$c])); return $out; } function al_mask(string $v): string { $v = trim($v); if ($v === '') return ''; return '••••' . (strlen($v) > 8 ? substr($v, -4) : ''); } /** Cabeçalhos com os valores mascarados (podem ter tokens). */ function al_mask_headers(string $h): string { $o = []; foreach (preg_split('/\r?\n/', trim($h)) as $l) { if (trim($l) === '') continue; $p = strpos($l, ':'); $o[] = $p === false ? al_mask($l) : substr($l, 0, $p) . ': ' . al_mask(substr($l, $p + 1)); } return implode("\n", $o); } /** Versão para o navegador: segredos nunca voltam, só mascarados. */ function al_public(array $s): array { $s['telegram']['token_set'] = $s['telegram']['token'] !== ''; $s['telegram']['token'] = al_mask($s['telegram']['token']); $s['whatsapp']['headers'] = al_mask_headers($s['whatsapp']['headers']); $s['whatsapp']['mode_eff'] = al_wa_mode($s['whatsapp']); return $s; } /* ------------------------------------------------------------------ WhatsApp deste servidor (GOWA) */ /** Configuração do serviço de WhatsApp local, criada pelo install-whatsapp.sh (LKM_DATA/gowa.json). */ function al_gowa(): ?array { $g = al_read('gowa.json'); $url = rtrim(trim((string)($g['url'] ?? '')), '/'); if ($url === '' || !preg_match('#^https?://[^\s/]+(/[^\s]*)?$#i', $url)) return null; $dev = (string)($g['device'] ?? 'lk'); if (!preg_match('/^[A-Za-z0-9_.@:-]{1,64}$/', $dev)) $dev = 'lk'; $port = (string)($g['page_port'] ?? ''); return ['url' => $url, 'user' => (string)($g['user'] ?? ''), 'pass' => (string)($g['pass'] ?? ''), 'device' => $dev, 'page_port' => preg_match('/^\d{1,5}$/', $port) ? $port : '']; } /** O que o navegador pode saber do serviço local: nunca usuário nem senha. */ function al_gowa_public(): ?array { $g = al_gowa(); return $g ? ['device' => $g['device'], 'page_port' => $g['page_port']] : null; } /** Modo efetivo: quem já tinha webhook configurado continua nele; sem gowa.json, sempre webhook. */ function al_wa_mode(array $w): string { if (!al_gowa()) return 'webhook'; $m = (string)($w['mode'] ?? ''); if ($m === 'gowa' || $m === 'webhook') return $m; return trim((string)($w['url'] ?? '')) !== '' ? 'webhook' : 'gowa'; } /** Números de destino: aceita +, espaços, traços e parênteses; devolve [válidos, inválidos]. */ function al_numbers(string $s): array { $ok = []; $bad = []; foreach (preg_split('/[,;\n]+/', $s) as $n) { $raw = trim($n); if ($raw === '') continue; $d = preg_replace('/[\s+().-]/', '', $raw); if (preg_match('/^\d{10,15}$/', $d)) $ok[] = $d; else $bad[] = $raw; } return [array_values(array_unique($ok)), $bad]; } function al_gowa_headers(array $g): array { $h = ['Accept: application/json', 'X-Device-Id: ' . $g['device']]; if ($g['user'] !== '' || $g['pass'] !== '') $h[] = 'Authorization: Basic ' . base64_encode($g['user'] . ':' . $g['pass']); return $h; } /** Situação do número conectado: ok (o serviço respondeu), logged (true/false/null) e error em português. */ function al_gowa_status(): array { $g = al_gowa(); if (!$g) return ['ok' => false, 'logged' => null, 'error' => 'O WhatsApp deste servidor não está instalado.']; [$code, $body, $err] = al_http('GET', $g['url'] . '/devices/' . rawurlencode($g['device']) . '/status', al_gowa_headers($g), null, 6); if ($code === 0) return ['ok' => false, 'logged' => null, 'error' => al_gowa_down($err)]; if ($code === 401 || $code === 403) return ['ok' => false, 'logged' => null, 'error' => 'O serviço do WhatsApp recusou o usuário/senha (confira o gowa.json).']; $d = json_decode($body, true); if ($code === 404) return ['ok' => true, 'logged' => false, 'error' => 'Aparelho "' . $g['device'] . '" não encontrado no serviço do WhatsApp.']; if ($code >= 500 || !is_array($d)) return ['ok' => false, 'logged' => null, 'error' => 'O serviço do WhatsApp respondeu com erro (HTTP ' . $code . ').']; $r = (array)($d['results'] ?? []); return ['ok' => true, 'logged' => !empty($r['is_logged_in']), 'connected' => !empty($r['is_connected']), 'error' => null]; } function al_gowa_down(string $err): string { if (stripos($err, 'timed out') !== false || stripos($err, 'timeout') !== false) return 'O serviço do WhatsApp não respondeu a tempo (travado ou sobrecarregado).'; return 'O serviço do WhatsApp deste servidor está parado (sem resposta). Inicie-o de novo e tente outra vez.'; } /** Traduz a resposta de erro do envio pelo serviço local. */ function al_gowa_err(int $code, string $body, string $err, string $num): string { if ($code === 0) return al_gowa_down($err); if ($code === 401 || $code === 403) return 'o serviço do WhatsApp recusou o usuário/senha (confira o gowa.json)'; $d = json_decode($body, true); $m = is_array($d) ? trim((string)($d['message'] ?? $d['error'] ?? '')) : trim(strip_tags($body)); $low = strtolower($m . ' ' . (is_array($d) ? (string)($d['code'] ?? '') : '')); if (preg_match('/not (logged|login|connected)|logged ?out|unauthorized device|login first|scan|qr|session|disconnect/', $low)) { return 'número desconectado: abra a página do WhatsApp e leia o QR code'; } if (preg_match('/not (registered|on whatsapp|exist)|invalid (phone|jid|number)|is not a whatsapp/', $low)) return "o número $num não tem WhatsApp ou é inválido"; if (preg_match('/device.*not found|unknown device|device id/', $low)) return 'aparelho não encontrado no serviço do WhatsApp (confira o "device" do gowa.json)'; return 'erro do serviço do WhatsApp (HTTP ' . $code . ')' . ($m !== '' ? ': ' . mb_substr($m, 0, 160) : ''); } /** Envia pelo serviço local para cada número. true se todos receberam; senão, a mensagem de erro. */ function al_send_gowa(array $w, string $text): string|bool { $g = al_gowa(); if (!$g) return 'WhatsApp: o WhatsApp deste servidor não está instalado.'; [$nums] = al_numbers((string)$w['numbers']); if (!$nums) return 'WhatsApp: informe pelo menos um número de destino.'; $fail = []; foreach ($nums as $n) { [$code, $body, $err] = al_http('POST', $g['url'] . '/send/message', array_merge(al_gowa_headers($g), ['Content-Type: application/json']), json_encode(['phone' => $n . '@s.whatsapp.net', 'message' => $text], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 15); $d = json_decode($body, true); $okCode = !is_array($d) || !isset($d['code']) || in_array(strtoupper((string)$d['code']), ['SUCCESS', '200', 'OK'], true); if ($code >= 200 && $code < 300 && $okCode) continue; $e = al_gowa_err($code, $body, $err, $n); // sem detalhe claro: confere se o número está conectado if ($code !== 0 && !str_starts_with($e, 'número desconectado') && ($st = al_gowa_status()) && $st['ok'] && $st['logged'] === false) { $e = 'número desconectado: abra a página do WhatsApp e leia o QR code'; } if ($code === 0 || str_starts_with($e, 'número desconectado') || str_contains($e, 'usuário/senha')) return 'WhatsApp: ' . $e; $fail[] = (count($nums) > 1 ? "$n: " : '') . $e; } return $fail ? 'WhatsApp: ' . implode('; ', $fail) : true; } /** Junta o que veio da tela com o salvo: segredo vazio ou ainda mascarado mantém o valor antigo. */ function al_merge(array $in, array $old): array { $n = $old; $n['repeat'] = max(5, min(1440, (int)($in['repeat'] ?? $old['repeat']))); foreach (AL_DEFAULTS['rules'] as $k => $r) { if (!isset($in['rules'][$k])) continue; $x = (array)$in['rules'][$k]; $n['rules'][$k]['on'] = !empty($x['on']); if (isset($r['limit'])) $n['rules'][$k]['limit'] = max(1, min($k === 'ssh' ? 100000 : ($k === 'temp' ? 150 : 100), (int)($x['limit'] ?? $old['rules'][$k]['limit']))); if (isset($r['minutes'])) $n['rules'][$k]['minutes'] = max(1, min(120, (int)($x['minutes'] ?? $old['rules'][$k]['minutes']))); } $t = (array)($in['telegram'] ?? []); $n['telegram']['on'] = array_key_exists('on', $t) ? !empty($t['on']) : $old['telegram']['on']; $n['telegram']['chat'] = trim((string)($t['chat'] ?? $old['telegram']['chat'])); $tok = trim((string)($t['token'] ?? '')); if ($tok !== '' && !str_starts_with($tok, '••••')) $n['telegram']['token'] = $tok; if (!empty($t['clear_token'])) $n['telegram']['token'] = ''; $w = (array)($in['whatsapp'] ?? []); $n['whatsapp']['on'] = array_key_exists('on', $w) ? !empty($w['on']) : $old['whatsapp']['on']; if (in_array($w['mode'] ?? null, ['gowa', 'webhook'], true)) $n['whatsapp']['mode'] = $w['mode']; if (array_key_exists('numbers', $w)) { [$ok, $bad] = al_numbers((string)$w['numbers']); $n['whatsapp']['numbers'] = $bad ? trim((string)$w['numbers']) : implode(', ', $ok); } foreach (['url', 'body', 'phone'] as $k) $n['whatsapp'][$k] = trim((string)($w[$k] ?? $old['whatsapp'][$k])); $n['whatsapp']['method'] = in_array(strtoupper((string)($w['method'] ?? $old['whatsapp']['method'])), ['POST', 'GET', 'PUT'], true) ? strtoupper((string)($w['method'] ?? $old['whatsapp']['method'])) : 'POST'; $h = trim((string)($w['headers'] ?? '')); if (array_key_exists('headers', $w) && $h !== al_mask_headers($old['whatsapp']['headers'])) { // linha mascarada que não mudou mantém o valor antigo daquele cabeçalho $oldH = []; foreach (preg_split('/\r?\n/', $old['whatsapp']['headers']) as $l) if (($p = strpos($l, ':')) !== false) $oldH[strtolower(trim(substr($l, 0, $p)))] = $l; $lines = []; foreach (preg_split('/\r?\n/', $h) as $l) { if (trim($l) === '') continue; $p = strpos($l, ':'); $name = $p === false ? '' : strtolower(trim(substr($l, 0, $p))); $lines[] = $p !== false && str_starts_with(trim(substr($l, $p + 1)), '••••') && isset($oldH[$name]) ? $oldH[$name] : $l; } $n['whatsapp']['headers'] = implode("\n", $lines); } return $n; } function al_validate(array $s): ?string { if ($s['telegram']['on'] && ($s['telegram']['token'] === '' || $s['telegram']['chat'] === '')) return 'Telegram: informe o token do bot e o chat id.'; if ($s['telegram']['token'] !== '' && !preg_match('/^\d+:[A-Za-z0-9_-]{20,}$/', $s['telegram']['token'])) return 'Telegram: o token do bot parece inválido (formato 123456:ABC...).'; $w = $s['whatsapp']; if (al_wa_mode($w) === 'gowa') { [$ok, $bad] = al_numbers($w['numbers']); if ($bad) return 'WhatsApp: número inválido: "' . $bad[0] . '". Use só dígitos com código do país e DDD (ex.: 5511999998888).'; if ($w['on'] && !$ok) return 'WhatsApp: informe pelo menos um número de destino.'; return null; } if ($w['url'] !== '' && !preg_match('#^https?://[^\s]+$#i', $w['url'])) return 'WhatsApp: a URL precisa começar com http:// ou https://'; if ($w['on'] && ($w['url'] === '' || $w['phone'] === '')) return 'WhatsApp: informe a URL do gateway e o número de destino.'; foreach (preg_split('/\r?\n/', trim($w['headers'])) as $l) if (trim($l) !== '' && !preg_match('/^[A-Za-z0-9-]+\s*:/', $l)) return 'WhatsApp: cabeçalho inválido: "' . $l . '" (use Nome: valor)'; if ($w['method'] !== 'GET' && $w['body'] !== '' && str_starts_with(ltrim($w['body']), '{')) { $probe = str_replace(['{phone}', '{message}'], ['x', 'y'], $w['body']); if (json_decode($probe) === null) return 'WhatsApp: o corpo não é um JSON válido.'; } return null; } /* ------------------------------------------------------------------ envio */ function al_http(string $method, string $url, array $headers, ?string $body, int $timeout = 10): array { if (function_exists('curl_init')) { $c = curl_init($url); curl_setopt_array($c, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 6, CURLOPT_HTTPHEADER => $headers, CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => 'LK-Monitor']); if ($body !== null && $method !== 'GET') curl_setopt($c, CURLOPT_POSTFIELDS, $body); $out = curl_exec($c); $code = (int)curl_getinfo($c, CURLINFO_RESPONSE_CODE); $err = curl_error($c); curl_close($c); return [$code, is_string($out) ? $out : '', $err]; } $ctx = stream_context_create(['http' => ['method' => $method, 'header' => implode("\r\n", array_merge($headers, ['User-Agent: LK-Monitor'])), 'content' => $method !== 'GET' ? (string)$body : '', 'timeout' => $timeout, 'ignore_errors' => true, 'follow_location' => 0]]); $out = @file_get_contents($url, false, $ctx); $code = 0; foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d+)#', $h, $m)) $code = (int)$m[1]; return [$code, is_string($out) ? $out : '', $out === false && !$code ? 'sem resposta' : '']; } function al_send_telegram(array $t, string $text): string|bool { [$code, $body, $err] = al_http('POST', 'https://api.telegram.org/bot' . $t['token'] . '/sendMessage', ['Content-Type: application/json'], json_encode(['chat_id' => $t['chat'], 'text' => $text, 'disable_web_page_preview' => true], JSON_UNESCAPED_UNICODE)); if ($code === 200) return true; $d = json_decode($body, true); return 'Telegram' . ($code ? " $code" : '') . ': ' . (is_array($d) && isset($d['description']) ? $d['description'] : ($err ?: 'falha no envio')); } function al_send_whatsapp(array $w, string $text): string|bool { if (al_wa_mode($w) === 'gowa') return al_send_gowa($w, $text); $isJson = str_starts_with(ltrim($w['body']), '{') || str_starts_with(ltrim($w['body']), '['); $esc = fn(string $v) => $isJson ? substr(json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 1, -1) : $v; $url = str_replace(['{phone}', '{message}'], [rawurlencode($w['phone']), rawurlencode($text)], $w['url']); $headers = []; $hasCt = false; foreach (preg_split('/\r?\n/', trim($w['headers'])) as $l) { if (trim($l) === '') continue; $headers[] = trim($l); if (stripos(ltrim($l), 'content-type:') === 0) $hasCt = true; } $body = null; if ($w['method'] !== 'GET') { $body = str_replace(['{phone}', '{message}'], [$esc($w['phone']), $esc($text)], $w['body']); if (!$hasCt) $headers[] = 'Content-Type: ' . ($isJson ? 'application/json' : 'application/x-www-form-urlencoded'); } [$code, $resp, $err] = al_http($w['method'], $url, $headers, $body); if ($code >= 200 && $code < 300) return true; return 'WhatsApp' . ($code ? " $code" : '') . ': ' . ($err ?: mb_substr(trim(strip_tags($resp)), 0, 160) ?: 'falha no envio'); } /** Envia para os canais ativos (ou só os pedidos). Devolve canal => true | mensagem de erro. */ function al_send(string $text, array $s, ?array $only = null): array { $r = []; foreach (['telegram', 'whatsapp'] as $c) { if ($only !== null ? !in_array($c, $only, true) : empty($s[$c]['on'])) continue; try { $r[$c] = $c === 'telegram' ? al_send_telegram($s[$c], $text) : al_send_whatsapp($s[$c], $text); } catch (Throwable $e) { $r[$c] = $e->getMessage(); } } return $r; } /* ------------------------------------------------------------------ verificações */ function al_services(): array { $c = ['apache2', 'mariadb', 'mysql', 'ssh', 'sshd', 'supervisor']; foreach (glob('/etc/php/*/fpm', GLOB_ONLYDIR) ?: [] as $d) $c[] = 'php' . basename(dirname($d)) . '-fpm'; $have = []; foreach ($c as $s) { foreach (['/usr/lib/systemd/system', '/lib/systemd/system', '/etc/systemd/system'] as $dir) { if (is_file("$dir/$s.service") && !is_link("$dir/$s.service")) { $have[$s] = 1; break; } } } if (isset($have['mariadb'])) unset($have['mysql']); if (isset($have['ssh'])) unset($have['sshd']); $names = array_keys($have); if (!$names) return []; $st = explode("\n", trim((string)shell_exec('systemctl is-active ' . implode(' ', array_map('escapeshellarg', $names)) . ' 2>/dev/null'))); $out = []; foreach ($names as $i => $n) $out[$n] = $st[$i] ?? 'unknown'; return $out; } /** Domínios dos sites ligados no Apache (porta 80). */ function al_sites(): array { $out = []; foreach (glob('/etc/apache2/sites-enabled/*.conf') ?: [] as $f) { $n = basename($f, '.conf'); if (in_array($n, ['000-default', 'default-ssl', 'lkmonitor'], true) || str_ends_with($n, '-le-ssl')) continue; if (preg_match('/^\s*ServerName\s+(\S+)/mi', (string)@file_get_contents($f), $m)) $out[] = strtolower($m[1]); } return array_values(array_unique($out)); } /** Testa os sites em paralelo em 127.0.0.1 com o Host de cada um: domínio => código HTTP (0 = sem resposta). */ function al_check_sites(array $doms): array { $r = []; if (!$doms) return $r; if (function_exists('curl_multi_init')) { $mh = curl_multi_init(); $hs = []; foreach ($doms as $d) { $c = curl_init('http://127.0.0.1/'); curl_setopt_array($c, [CURLOPT_HTTPHEADER => ["Host: $d"], CURLOPT_RETURNTRANSFER => true, CURLOPT_NOBODY => false, CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 4, CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => 'LK-Monitor (verificação)', CURLOPT_RANGE => '0-1023']); curl_multi_add_handle($mh, $c); $hs[$d] = $c; } do { $st = curl_multi_exec($mh, $running); if ($running) curl_multi_select($mh, 1.0); } while ($running && $st === CURLM_OK); foreach ($hs as $d => $c) { $r[$d] = (int)curl_getinfo($c, CURLINFO_RESPONSE_CODE); curl_multi_remove_handle($mh, $c); curl_close($c); } curl_multi_close($mh); return $r; } foreach ($doms as $d) { [$code] = al_http('GET', 'http://127.0.0.1/', ["Host: $d"], null, 5); $r[$d] = $code; } return $r; } /** Falhas de login no SSH nos últimos 10 minutos (journal; se não der, /var/log/auth.log). */ function al_ssh_failures(): ?int { $re = '/Failed (password|publickey|keyboard-interactive\S*|none) for /'; exec("journalctl -q --no-pager -o cat --since '-10min' SYSLOG_IDENTIFIER=sshd SYSLOG_IDENTIFIER=sshd-session 2>/dev/null", $lines, $rc); if ($rc === 0) return preg_match_all($re, implode(" ", $lines)); $f = '/var/log/auth.log'; if (!is_readable($f)) return null; $h = fopen($f, 'rb'); $size = filesize($f); fseek($h, max(0, $size - 2 * 1024 * 1024)); $lines = explode("\n", (string)fread($h, 2 * 1024 * 1024)); fclose($h); $since = time() - 600; $n = 0; foreach ($lines as $l) { if (!preg_match($re, $l)) continue; $t = preg_match('/^(\d{4}-\d\d-\d\dT[\d:.]+[+-]\d\d:?\d\d)/', $l, $m) ? strtotime($m[1]) : strtotime(substr($l, 0, 15)); if ($t && $t >= $since) $n++; } return $n; } /* ------------------------------------------------------------------ avaliação (coletor) */ function al_history(int $n = AL_HIST_MAX): array { return array_slice(al_read('alerts.json'), 0, $n); } function al_log(array $ev): void { $h = al_read('alerts.json'); array_unshift($h, $ev); al_write('alerts.json', array_slice($h, 0, AL_HIST_MAX)); } /** * Avalia as regras. $m = métricas atuais do coletor; $db = SQLite do histórico (para "por Y minutos"). * Dispara quando a condição começa, repete a cada N minutos enquanto continuar e avisa quando volta ao normal. */ function al_eval(array $m, ?PDO $db): array { $s = al_settings(); $R = $s['rules']; $state = al_read('alerts.state.json'); $now = time(); $host = gethostname(); $checks = []; // chave => [ativo?, mensagem de alerta, mensagem de normal, precisa de N falhas seguidas] $window = function (string $expr, int $min) use ($db, $now): ?array { if (!$db) return null; try { $st = $db->query("SELECT $expr FROM m WHERE ts > " . ($now - $min * 60 - 20) . ' ORDER BY ts'); return array_map(fn($v) => $v === null ? null : (float)$v, $st->fetchAll(PDO::FETCH_COLUMN)); } catch (Throwable $e) { return null; } }; if ($R['temp']['on'] && $m['temp'] !== null) { $l = $R['temp']['limit']; $mn = $R['temp']['minutes']; $v = array_filter($window('temp', $mn) ?? [], fn($x) => $x !== null); $hot = count($v) >= max(1, $mn - 1) && min($v) > $l; $was = !empty($state['temp']['active']); $checks['temp'] = [$hot || ($was && $m['temp'] > $l - 3), "🔥 Temperatura da CPU em {$m['temp']} °C (acima de $l °C há $mn min)", "✅ Temperatura da CPU normalizou: {$m['temp']} °C", 1]; } if ($R['ram']['on'] && $m['mem_pct'] !== null) { $l = $R['ram']['limit']; $mn = $R['ram']['minutes']; $v = array_filter($window('CASE WHEN mem_total > 0 THEN mem * 100.0 / mem_total END', $mn) ?? [], fn($x) => $x !== null); $high = count($v) >= max(1, $mn - 1) && min($v) > $l; $was = !empty($state['ram']['active']); $p = round($m['mem_pct']); $checks['ram'] = [$high || ($was && $m['mem_pct'] > $l - 2), "🧠 Memória RAM em $p% há $mn min (limite $l%)", "✅ Memória RAM normalizou: $p%", 1]; } if ($R['disk']['on']) { $l = $R['disk']['limit']; foreach ($m['disks'] as $d) { if (!$d['total']) continue; $p = round($d['used'] / $d['total'] * 100); $k = 'disk:' . $d['mnt']; $was = !empty($state[$k]['active']); $checks[$k] = [$p > $l || ($was && $p > $l - 2), "💾 Disco {$d['mnt']} com $p% de uso (limite $l%)", "✅ Disco {$d['mnt']} voltou para $p%", 1]; } } if ($R['service']['on']) { foreach (al_services() as $n => $st) { $checks['svc:' . $n] = [$st !== 'active', "🛑 Serviço $n parado ($st)", "✅ Serviço $n voltou a rodar", 2]; } } if ($R['site']['on']) { foreach (al_check_sites(al_sites()) as $d => $code) { $bad = $code === 0 || $code >= 500; $checks['site:' . $d] = [$bad, "🌐 Site $d fora do ar (" . ($code ? "HTTP $code" : 'sem resposta') . ')', "✅ Site $d voltou (HTTP $code)", 2]; } } if ($R['ssh']['on']) { $n = al_ssh_failures(); if ($n !== null) { $l = $R['ssh']['limit']; $checks['ssh'] = [$n > $l, "🔐 $n falhas de login no SSH nos últimos 10 min (limite $l)", "✅ Falhas de login no SSH voltaram ao normal ($n em 10 min)", 1]; } } $events = []; $newState = []; foreach ($checks as $k => [$bad, $msgA, $msgN, $need]) { $st = $state[$k] ?? ['active' => false, 'fails' => 0]; $st['fails'] = $bad ? (int)($st['fails'] ?? 0) + 1 : 0; if ($bad && $st['fails'] >= $need) { if (empty($st['active'])) { $st = ['active' => true, 'since' => $now, 'sent' => $now, 'fails' => $st['fails']]; $events[] = ['kind' => 'alert', 'key' => $k, 'msg' => $msgA]; } elseif ($now - (int)($st['sent'] ?? 0) >= $s['repeat'] * 60 - 30) { $st['sent'] = $now; $events[] = ['kind' => 'repeat', 'key' => $k, 'msg' => '⏰ Continua: ' . $msgA . ' · desde ' . date('H:i', (int)$st['since'])]; } } elseif (!$bad && !empty($st['active'])) { $events[] = ['kind' => 'ok', 'key' => $k, 'msg' => $msgN . ' · durou ' . al_dur($now - (int)$st['since'])]; $st = ['active' => false, 'fails' => 0]; } $newState[$k] = $st; } // regra desligada ou item que sumiu: esquece sem avisar al_write('alerts.state.json', $newState); foreach ($events as $ev) { $text = '[' . $host . '] ' . $ev['msg']; $sent = al_send($text, $s); al_log(['t' => $now, 'kind' => $ev['kind'], 'key' => $ev['key'], 'msg' => $ev['msg'], 'sent' => $sent]); } return $events; } function al_dur(int $s): string { if ($s < 3600) return max(1, intdiv($s, 60)) . ' min'; if ($s < 86400) return intdiv($s, 3600) . ' h ' . intdiv($s % 3600, 60) . ' min'; return intdiv($s, 86400) . ' d ' . intdiv($s % 86400, 3600) . ' h'; } /** Situação atual para a tela (alertas ativos). */ function al_active(): array { $out = []; foreach (al_read('alerts.state.json') as $k => $st) if (!empty($st['active'])) $out[] = ['key' => $k, 'since' => $st['since'] ?? null]; return $out; } LKM_EOF_ALERTS cat > "$LIBDIR/sys.php" <<'LKM_EOF_SYS' true] + $d, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } function fail(string $m): never { echo json_encode(['ok' => false, 'error' => $m], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } /** Roda um comando (argv, sem shell). Retorna [código, saída sem cores]. */ function run(array $argv, int $timeout = 60): array { $argv = array_merge(['/usr/bin/timeout', '-k', '5', (string)$timeout], $argv); $p = @proc_open($argv, [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['redirect', 1]], $pipes, null, ['PATH' => getenv('PATH'), 'LANG' => 'C.UTF-8', 'TERM' => 'dumb', 'SYSTEMD_COLORS' => '0']); if (!is_resource($p)) return [1, 'Não foi possível executar ' . $argv[4]]; $o = (string)stream_get_contents($pipes[1]); fclose($pipes[1]); $code = proc_close($p); $o = preg_replace('/\e\[[0-9;?]*[ -\/]*[@-~]|\r(?!\n)/', '', $o); return [$code, trim($o)]; } /** Agenda um comando para daqui a $sec segundos, fora do Apache (sobrevive ao reinício dele). */ function later(array $argv, int $sec = 3): void { $unit = 'lkm-acao-' . bin2hex(random_bytes(4)); [$c, $o] = run(array_merge(['systemd-run', '--quiet', '--collect', '--unit=' . $unit, '--on-active=' . $sec, '--timer-property=AccuracySec=100ms'], $argv), 20); if ($c !== 0) fail('Falha ao agendar: ' . ($o ?: 'systemd-run')); } function need_root(): void { if (function_exists('posix_geteuid') ? posix_geteuid() !== 0 : trim((string)shell_exec('id -u')) !== '0') { fail('Essa ação precisa do terminal do LK Monitor como root. No servidor: sudo lk › opção 33 › instalar / atualizar e escolha root.'); } } function unit_exists(string $s): bool { foreach (['/usr/lib/systemd/system', '/lib/systemd/system', '/etc/systemd/system'] as $dir) { if (is_file("$dir/$s.service")) return true; } return false; } /** Mesma lista do card "Serviços" do painel. */ function known_service(string $s): bool { if (!preg_match('/^[a-z0-9][a-z0-9.@_-]{0,60}$/', $s)) return false; $ok = in_array($s, SVC_CANDIDATES, true) || (bool)preg_match('/^php\d+\.\d+-fpm$/', $s); return $ok && unit_exists($s); } /** Serviços que mantêm o próprio painel no ar. */ function is_panel_service(string $s): bool { return $s === 'apache2' || (bool)preg_match('/^php\d+\.\d+-fpm$/', $s); } function mode_info(): array { [, $def] = run(['systemctl', 'get-default'], 10); [$c] = run(['systemctl', 'is-active', '--quiet', 'graphical.target'], 10); return [ 'default' => $def, 'server' => $def === 'multi-user.target', 'desktop_now' => $c === 0, 'has_desktop' => unit_exists('display-manager') || is_link('/etc/systemd/system/display-manager.service'), 'lk' => is_executable(LK_BIN), 'root' => function_exists('posix_geteuid') ? posix_geteuid() === 0 : trim((string)shell_exec('id -u')) === '0', ]; } /* ------------------------------------------------------------------ */ $req = json_decode((string)stream_get_contents(STDIN), true); if (!is_array($req)) fail('Pedido inválido'); $act = (string)($req['action'] ?? ''); switch ($act) { case 'status': out(['mode' => mode_info()]); case 'service': need_root(); $name = (string)($req['name'] ?? ''); $op = (string)($req['op'] ?? ''); if (!in_array($op, ['start', 'stop', 'restart'], true)) fail('Ação inválida'); if (!known_service($name)) fail('Serviço desconhecido: ' . $name); if (is_panel_service($name)) { if ($op === 'stop') fail($name . ' mantém o LK Monitor no ar. Parar pelo painel deixaria o painel fora do ar: use Reiniciar, ou pare pelo terminal do servidor.'); later(['/bin/systemctl', $op, $name], 2); out(['msg' => $name . ': reiniciando em 2 segundos. O painel pode piscar.', 'delayed' => true]); } [$c, $o] = run(['systemctl', $op, $name], 120); if ($c !== 0) fail('Falha ao executar ' . $op . ' em ' . $name . ($o !== '' ? ': ' . $o : '')); [, $st] = run(['systemctl', 'is-active', $name], 10); $verb = ['start' => 'iniciado', 'stop' => 'parado', 'restart' => 'reiniciado'][$op]; out(['msg' => $name . ' ' . $verb, 'state' => $st]); case 'kill': need_root(); $pid = (int)($req['pid'] ?? 0); $sig = ($req['sig'] ?? '') === 'KILL' ? 'KILL' : 'TERM'; if ($pid <= 1) fail('Processo inválido'); $stat = @file_get_contents("/proc/$pid/stat"); if ($stat === false) fail('O processo já terminou.'); $a = strpos($stat, '('); $b = strrpos($stat, ')'); $comm = substr($stat, $a + 1, $b - $a - 1); $ppid = (int)(explode(' ', substr($stat, $b + 2))[1] ?? 0); if (isset($req['name']) && (string)$req['name'] !== $comm) fail('O PID ' . $pid . ' agora é outro processo (' . $comm . '). Nada foi feito.'); if ($ppid === 2 || $pid === 2) fail('Processos do kernel não podem ser encerrados.'); for ($p = getmypid(); $p > 1;) { // nunca o próprio painel (este helper, sudo, php-fpm…) if ($p === $pid) fail('Esse processo faz parte do próprio LK Monitor.'); $s = @file_get_contents("/proc/$p/stat"); if ($s === false) break; $p = (int)(explode(' ', substr($s, strrpos($s, ')') + 2))[1] ?? 0); } [$c, $o] = run(['/bin/kill', '-s', $sig, (string)$pid], 10); if ($c !== 0) fail('Não foi possível encerrar ' . $comm . ($o !== '' ? ': ' . $o : '')); out(['msg' => ($sig === 'KILL' ? 'Forçado o fim de ' : 'Pedido para encerrar ') . $comm . ' (PID ' . $pid . ')']); case 'mode': need_root(); $to = (string)($req['to'] ?? ''); $when = (string)($req['when'] ?? 'boot'); if (!in_array($to, ['server', 'desktop'], true) || !in_array($when, ['agora', 'reiniciar', 'boot'], true)) fail('Pedido inválido'); if (!is_executable(LK_BIN)) fail('Comando lk não encontrado em ' . LK_BIN . '. Rode o install-lk.sh de novo.'); // configura tudo (GRUB, tampa, suspensão, thermald, perfil de energia) sem trocar a tela agora [$c, $o] = run([LK_BIN, 'servidor', $to === 'server' ? 'on' : 'off', 'boot'], 600); if ($c !== 0) fail("O lk terminou com erro:\n" . $o); $msg = $to === 'server' ? 'Modo servidor ativado' : 'Modo normal (com tela) ativado'; if ($when === 'agora') { later(['/bin/systemctl', 'isolate', $to === 'server' ? 'multi-user.target' : 'graphical.target'], 2); $msg .= $to === 'server' ? '. Desligando a área de trabalho…' : '. Ligando a área de trabalho…'; } elseif ($when === 'reiniciar') { later(['/bin/systemctl', 'reboot'], 3); $msg .= '. Reiniciando em 3 segundos…'; } else { $msg .= '. Vale a partir do próximo boot.'; } out(['msg' => $msg, 'log' => $o, 'mode' => mode_info()]); case 'rootpass': // troca a senha do root do Linux (SSH e login local). A senha chega pelo stdin e vai direto para o chpasswd (nunca na linha de comando nem no log). need_root(); $pw = (string)($req['pass'] ?? ''); if (strlen($pw) < 10 || strlen($pw) > 200 || preg_match('/[\x00-\x1f:]/', $pw)) fail('Senha inválida para o Linux (10 a 200 caracteres, sem ":").'); $p = @proc_open(['/usr/sbin/chpasswd'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($p)) fail('Não foi possível rodar o chpasswd.'); fwrite($pipes[0], 'root:' . $pw . "\n"); fclose($pipes[0]); $e = trim((string)stream_get_contents($pipes[2])); fclose($pipes[1]); fclose($pipes[2]); if (proc_close($p) !== 0) fail('O Linux recusou a senha: ' . ($e ?: 'chpasswd')); $sshd = is_executable('/usr/sbin/sshd') ? '/usr/sbin/sshd' : ''; $on = null; if ($sshd && !empty($req['ssh'])) { // liga o login do root com senha (mesmo arquivo da opção "Permitir login root com senha" do lk ssh) $f = '/etc/ssh/sshd_config.d/10-root.conf'; $old = is_file($f) ? (string)file_get_contents($f) : null; @mkdir('/etc/ssh/sshd_config.d', 0755, true); file_put_contents($f, "# LkPanel: root entra no SSH com senha\nPermitRootLogin yes\nPasswordAuthentication yes\n"); chmod($f, 0644); [$c, $e] = run([$sshd, '-t'], 10); if ($c !== 0) { if ($old === null) @unlink($f); else file_put_contents($f, $old); fail('Senha do root trocada, mas a configuração do SSH ficou inválida e foi desfeita: ' . $e); } foreach (['ssh', 'sshd'] as $u) if (unit_exists($u)) { run(['systemctl', 'enable', '--now', $u], 30); run(['systemctl', 'try-reload-or-restart', $u], 30); break; } } // o SSH aceita o root com senha? $t = ''; if ($sshd) { [$c, $t] = run([$sshd, '-T'], 10); if ($c !== 0) $t = ''; } $prl = preg_match('/^permitrootlogin\s+(\S+)/m', $t, $m) ? $m[1] : ''; $pa = preg_match('/^passwordauthentication\s+(\S+)/m', $t, $m) ? $m[1] : ''; out(['msg' => 'Senha do root trocada', 'ssh' => $t === '' ? null : ($prl === 'yes' && $pa === 'yes'), 'ssh_installed' => $sshd !== '']); case 'power': need_root(); $op = (string)($req['op'] ?? ''); if (!in_array($op, ['reboot', 'poweroff'], true)) fail('Ação inválida'); later(['/bin/systemctl', $op], 3); out(['msg' => $op === 'reboot' ? 'Reiniciando em 3 segundos…' : 'Desligando em 3 segundos…']); } fail('Ação inválida'); LKM_EOF_SYS cat > "$LIBDIR/apps.inc.php" <<'LKM_EOF_APPSINC' 'Banco de dados', 'web' => 'Web e PHP', 'docker' => 'Docker', 'net' => 'Rede e segurança', 'ia' => 'IA', 'sys' => 'Sistema']; /** Remove só os pacotes da lista que estão instalados (apt reclama de pacote que não existe no repositório). */ function app_rm_pkgs(array $pkgs, string $verb = 'remove'): string { $l = implode(' ', array_map('escapeshellarg', $pkgs)); return 'P=$(dpkg-query -W -f=\'${binary:Package} ${db:Status-Abbrev}\n\' ' . $l . ' 2>/dev/null | awk \'$2 ~ /^ii/ {print $1}\'); ' . '[ -n "$P" ] && ' . APT . ' ' . $verb . ' $P || echo "Nenhum pacote para remover"'; } /** Container Docker: confere o Docker e a porta, baixa a imagem e cria o container. */ function app_docker(string $name, int $port, string $image, string $run): array { return [ ['Conferir Docker e porta ' . $port, 'command -v docker >/dev/null || { echo "Instale o Docker primeiro (App Store › Docker)"; exit 1; }; ' . 'systemctl is-active --quiet docker || systemctl start docker; ' . 'docker inspect ' . $name . ' >/dev/null 2>&1 && { echo "Container ' . $name . ' já existe: removendo para recriar (os dados ficam)"; docker rm -f ' . $name . '; }; ' . 'if ss -ltnH "sport = :' . $port . '" | grep -q .; then echo "A porta ' . $port . ' já está em uso:"; ss -ltnpH "sport = :' . $port . '"; exit 1; fi; echo OK'], ['Baixar a imagem ' . $image, 'docker pull ' . $image], ['Criar o container ' . $name, 'docker run -d --name ' . $name . ' ' . $run . ' ' . $image], ]; } function app_docker_rm(string $name): array { return [['Remover o container ' . $name . ' (os dados ficam guardados)', 'docker rm -f ' . $name . ' 2>/dev/null || echo "Container já não existe"']]; } function apps_catalog(): array { $pma = <<<'BASH_PMA' set -e S=$(ls /run/php/ 2>/dev/null | grep -E '^php[0-9]+\.[0-9]+-fpm\.sock$' | sort -V | tail -1) [ -n "$S" ] || { echo "PHP-FPM não encontrado. Instale o LAMP antes: sudo lk › opção 1"; exit 1; } V=${S#php}; V=${V%-fpm.sock} echo "PHP $V" DEBIAN_FRONTEND=noninteractive apt-get -y -o DPkg::Lock::Timeout=300 install php$V-mysql php$V-mbstring php$V-xml php$V-zip php$V-gd php$V-curl >/dev/null || true D=/usr/share/lk-phpmyadmin T=$(mktemp -d) echo "Baixando phpMyAdmin..." curl -fsSL https://www.phpmyadmin.net/downloads/phpMyAdmin-latest-all-languages.tar.gz -o "$T/pma.tgz" mkdir -p "$T/x" && tar xzf "$T/pma.tgz" -C "$T/x" --strip-components=1 K=$(head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32) mkdir -p /var/lib/lk-phpmyadmin/tmp && chown -R www-data:www-data /var/lib/lk-phpmyadmin cat > "$T/x/config.inc.php" < /etc/apache2/conf-available/lk-phpmyadmin.conf < DirectoryIndex index.php Require ip 127.0.0.1 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 SetHandler "proxy:unix:/run/php/$S|fcgi://localhost" Require all denied Require all denied APCONF a2enmod -q proxy_fcgi >/dev/null a2enconf -q lk-phpmyadmin >/dev/null apache2ctl configtest systemctl reload apache2 echo "phpMyAdmin em http://$(hostname -I | awk '{print $1}')/phpmyadmin" BASH_PMA; return [ /* ---------------- banco de dados */ ['id' => 'mariadb', 'name' => 'MariaDB', 'cat' => 'db', 'color' => '#c0765a', 'pkgs' => ['mariadb-server', 'mariadb-client'], 'svc' => 'mariadb', 'desc' => 'Banco de dados MySQL usado pelos sites (Laravel, WordPress…). Gerencie na aba Bancos.', 'protect' => 'Os bancos dos seus sites ficam no MariaDB. Para remover, use o terminal do servidor.'], ['id' => 'phpmyadmin', 'name' => 'phpMyAdmin', 'cat' => 'db', 'color' => '#f29111', 'path' => '/phpmyadmin', 'needs' => 'mariadb', 'file' => '/usr/share/lk-phpmyadmin/index.php', 'desc' => 'Gerenciar o MariaDB pelo navegador. Só abre na rede local.', 'install' => [['Instalar phpMyAdmin e ligar no Apache (/phpmyadmin)', $pma]], 'remove' => [['Remover phpMyAdmin', 'a2disconf -q lk-phpmyadmin 2>/dev/null; rm -f /etc/apache2/conf-available/lk-phpmyadmin.conf; rm -rf /usr/share/lk-phpmyadmin /var/lib/lk-phpmyadmin; systemctl reload apache2; echo OK']], 'note' => 'Entre com o usuário e a senha de um banco (aba Bancos). O root do MariaDB só entra pelo terminal.'], ['id' => 'postgresql', 'name' => 'PostgreSQL', 'cat' => 'db', 'color' => '#336791', 'pkgs' => ['postgresql'], 'svc' => 'postgresql', 'desc' => 'Banco de dados avançado, muito usado com Node, Python e Laravel.', 'note' => 'Entrar no console: sudo -u postgres psql'], ['id' => 'redis', 'name' => 'Redis', 'cat' => 'db', 'color' => '#d82c20', 'pkgs' => ['redis-server'], 'svc' => 'redis-server', 'desc' => 'Cache e filas em memória (cache, sessões e queue do Laravel).'], ['id' => 'memcached', 'name' => 'Memcached', 'cat' => 'db', 'color' => '#2a7a8c', 'pkgs' => ['memcached'], 'svc' => 'memcached', 'desc' => 'Cache em memória simples e rápido.'], /* ---------------- web e PHP */ ['id' => 'node', 'name' => 'Node.js + PM2', 'cat' => 'web', 'color' => '#5fa04e', 'bin' => 'node', 'ver' => 'node -v', 'desc' => 'Node.js 22 LTS (NodeSource) e o PM2 para manter apps Node ligados. Igual à opção 8 do lk.', 'install' => [ ['Adicionar o repositório NodeSource 22.x', 'curl -fsSL https://deb.nodesource.com/setup_22.x | bash -'], ['Instalar Node.js', APT . ' install nodejs'], ['Instalar PM2', 'npm install -g pm2 && pm2 --version'], ], 'remove' => [['Remover PM2', 'command -v npm >/dev/null && npm rm -g pm2 || true'], ['Remover Node.js', app_rm_pkgs(['nodejs'])]], 'note' => 'Apps Node: sudo lk › opções 9 a 12.'], ['id' => 'composer', 'name' => 'Composer', 'cat' => 'web', 'color' => '#885630', 'bin' => 'composer', 'ver' => 'COMPOSER_ALLOW_SUPERUSER=1 composer --version 2>/dev/null | head -1', 'desc' => 'Gerenciador de pacotes do PHP (Laravel). Usado pelo deploy dos sites.', 'install' => [['Instalar Composer em /usr/local/bin', 'command -v php >/dev/null || { echo "PHP não encontrado. Instale o LAMP antes: sudo lk › opção 1"; exit 1; }; ' . 'curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php && php /tmp/composer-setup.php --quiet --install-dir=/usr/local/bin --filename=composer; rc=$?; rm -f /tmp/composer-setup.php; exit $rc']], 'remove' => [['Remover Composer', 'rm -f /usr/local/bin/composer; echo OK']]], ['id' => 'git', 'name' => 'Git', 'cat' => 'web', 'color' => '#f05133', 'pkgs' => ['git'], 'desc' => 'Controle de versão. Clonar e atualizar projetos do GitHub.'], ['id' => 'supervisor', 'name' => 'Supervisor', 'cat' => 'web', 'color' => '#6b7280', 'pkgs' => ['supervisor'], 'svc' => 'supervisor', 'desc' => 'Mantém workers rodando (queue:work do Laravel). Igual à opção 13 do lk.', 'note' => 'Criar worker: sudo lk › opção 14.'], ['id' => 'certbot', 'name' => 'Certbot (SSL)', 'cat' => 'web', 'color' => '#003a70', 'pkgs' => ['certbot', 'python3-certbot-apache'], 'desc' => 'Certificados HTTPS grátis do Let\'s Encrypt para os domínios dos sites.'], /* ---------------- docker */ ['id' => 'docker', 'name' => 'Docker', 'cat' => 'docker', 'color' => '#1d63ed', 'bin' => 'docker', 'ver' => 'docker --version', 'svc' => 'docker', 'desc' => 'Roda apps em containers. Necessário para Portainer, Uptime Kuma, Jellyfin e Open WebUI.', 'install' => [['Atualizar lista de pacotes', APT . ' update'], ['Instalar Docker e Docker Compose', APT . ' install docker.io docker-compose-v2'], ['Ligar o Docker no boot', 'systemctl enable --now docker && docker --version']], 'remove' => [['Remover Docker (containers, imagens e volumes ficam em /var/lib/docker)', app_rm_pkgs(['docker.io', 'docker-ce', 'docker-ce-cli', 'containerd.io', 'docker-compose-v2', 'docker-compose-plugin', 'docker-buildx-plugin'])]]], ['id' => 'portainer', 'name' => 'Portainer', 'cat' => 'docker', 'color' => '#13bef9', 'docker' => 'lk-portainer', 'port' => 9443, 'https' => true, 'needs' => 'docker', 'desc' => 'Painel visual para gerenciar containers, imagens e volumes do Docker.', 'install' => app_docker('lk-portainer', 9443, 'portainer/portainer-ce:lts', '--restart=always -p 9443:9443 -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data'), 'remove' => app_docker_rm('lk-portainer'), 'note' => 'Crie o usuário admin nos primeiros 5 minutos depois de instalar. O navegador avisa do certificado: é normal.'], ['id' => 'uptimekuma', 'name' => 'Uptime Kuma', 'cat' => 'docker', 'color' => '#5cdd8b', 'docker' => 'lk-uptime-kuma', 'port' => 3001, 'needs' => 'docker', 'desc' => 'Monitora se seus sites estão no ar e avisa quando caem.', 'install' => app_docker('lk-uptime-kuma', 3001, 'louislam/uptime-kuma:1', '--restart=always -p 3001:3001 -v uptime-kuma:/app/data'), 'remove' => app_docker_rm('lk-uptime-kuma')], ['id' => 'jellyfin', 'name' => 'Jellyfin', 'cat' => 'docker', 'color' => '#aa5cc3', 'docker' => 'lk-jellyfin', 'port' => 8096, 'needs' => 'docker', 'desc' => 'Seu próprio Netflix: filmes, séries e músicas na TV e no celular.', 'install' => array_merge([['Criar a pasta de mídia /srv/midia', 'mkdir -p /srv/midia && chmod 775 /srv/midia && echo OK']], app_docker('lk-jellyfin', 8096, 'jellyfin/jellyfin:latest', '--restart=unless-stopped -p 8096:8096 -v jellyfin-config:/config -v jellyfin-cache:/cache -v /srv/midia:/media')), 'remove' => app_docker_rm('lk-jellyfin'), 'note' => 'Coloque os vídeos em /srv/midia (aba Arquivos). No Jellyfin a pasta aparece como /media.'], ['id' => 'openwebui', 'name' => 'Open WebUI', 'cat' => 'ia', 'color' => '#9ca3af', 'docker' => 'lk-open-webui', 'port' => 3080, 'needs' => 'docker', 'desc' => 'Chat estilo ChatGPT no navegador para os modelos do Ollama.', 'install' => app_docker('lk-open-webui', 3080, 'ghcr.io/open-webui/open-webui:main', '--restart=always --network=host -e PORT=3080 -e OLLAMA_BASE_URL=http://127.0.0.1:11434 -v open-webui:/app/backend/data'), 'remove' => app_docker_rm('lk-open-webui'), 'note' => 'A primeira conta criada vira admin. Pode levar 1 ou 2 minutos para abrir depois de instalar.'], /* ---------------- IA */ ['id' => 'ollama', 'name' => 'Ollama', 'cat' => 'ia', 'color' => '#e5e7eb', 'bin' => 'ollama', 'ver' => 'ollama --version 2>/dev/null | tail -1', 'svc' => 'ollama', 'desc' => 'Roda modelos de IA (Llama, Qwen, Gemma…) no próprio servidor.', 'install' => [['Instalar Ollama (script oficial)', 'curl -fsSL https://ollama.com/install.sh | sh']], 'remove' => [['Remover Ollama (os modelos baixados ficam em /usr/share/ollama)', 'systemctl disable --now ollama 2>/dev/null; rm -f /etc/systemd/system/ollama.service /usr/local/bin/ollama /usr/bin/ollama; systemctl daemon-reload; echo OK']], 'note' => 'Baixar um modelo: ollama pull llama3.2 (no Terminal).'], /* ---------------- rede e segurança */ ['id' => 'fail2ban', 'name' => 'Fail2ban', 'cat' => 'net', 'color' => '#b91c1c', 'pkgs' => ['fail2ban'], 'svc' => 'fail2ban', 'desc' => 'Bloqueia IPs que erram a senha do SSH muitas vezes.', 'note' => 'Ver bloqueados: sudo fail2ban-client status sshd'], ['id' => 'tailscale', 'name' => 'Tailscale', 'cat' => 'net', 'color' => '#4b5563', 'bin' => 'tailscale', 'ver' => 'tailscale version 2>/dev/null | head -1', 'svc' => 'tailscaled', 'desc' => 'VPN fácil: acesse o servidor e o painel de qualquer lugar, sem abrir portas no roteador.', 'install' => [['Instalar Tailscale (script oficial)', 'curl -fsSL https://tailscale.com/install.sh | sh'], ['Apache e Tailscale juntos na porta 443', 'if [ -x /usr/bin/lk ] && [ -f /etc/apache2/ports.conf ]; then /usr/bin/lk apache-443; else echo "Sem Apache: nada a fazer"; fi']], 'remove' => [['Remover Tailscale', 'tailscale down 2>/dev/null; ' . app_rm_pkgs(['tailscale'])]], 'note' => 'Para conectar na sua conta: no Terminal rode sudo tailscale up e abra o link que aparecer.'], ['id' => 'cloudflared', 'name' => 'Cloudflare Tunnel', 'cat' => 'net', 'color' => '#f38020', 'bin' => 'cloudflared', 'ver' => 'cloudflared --version 2>/dev/null | head -1', 'svc' => 'lk-cloudflared', 'desc' => 'Coloca os sites e o painel na internet com HTTPS e o seu domínio, sem abrir portas no roteador.', 'install' => [ ['Adicionar o repositório da Cloudflare', 'install -m 0755 -d /usr/share/keyrings && curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg -o /usr/share/keyrings/cloudflare-main.gpg && chmod 644 /usr/share/keyrings/cloudflare-main.gpg ' . '&& echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" > /etc/apt/sources.list.d/cloudflared.list && chmod 644 /etc/apt/sources.list.d/cloudflared.list && echo OK'], ['Atualizar lista de pacotes', APT . ' update'], ['Instalar cloudflared', APT . ' install cloudflared && cloudflared --version'], ], 'remove' => [['Desligar os túneis do LK', 'systemctl disable --now lk-cloudflared lk-cf-quick 2>/dev/null; echo OK'], ['Remover cloudflared', app_rm_pkgs(['cloudflared'])]], 'note' => 'Configure na aba Internet do painel.'], ['id' => 'whatsapp', 'name' => 'WhatsApp + Bot', 'cat' => 'net', 'color' => '#25d366', 'file' => '/opt/lk-guard/guard.php', 'svc' => 'lk-guard', 'desc' => 'Avisos do servidor no seu WhatsApp e um bot que responde STATUS, REINICIAR e DESLIGAR.', 'install' => [['Instalar WhatsApp (GOWA), página do QR code e bot', 'WA_AUTO=1 bash /usr/local/share/lk/install-whatsapp.sh']], 'remove' => [['Remover WhatsApp e bot', 'WA_AUTO=1 bash /usr/local/share/lk/install-whatsapp.sh uninstall']], 'note' => 'Depois de instalar: abra a página (botão Abrir, mesma senha do painel) e conecte o número do servidor pelo QR code. Seu número para receber os avisos: no Terminal, sudo lk whatsapp guardiao. Ligar/desligar o bot: sudo lk bot on|off.'], ['id' => 'cockpit', 'name' => 'Cockpit', 'cat' => 'sys', 'color' => '#0066cc', 'pkgs' => ['cockpit'], 'svc' => 'cockpit.socket', 'port' => 9090, 'https' => true, 'desc' => 'Painel do Ubuntu: usuários, discos, rede, atualizações e logs.', 'install' => [['Atualizar lista de pacotes', APT . ' update'], ['Instalar Cockpit', APT . ' install --no-install-recommends cockpit'], ['Ligar o Cockpit', 'systemctl enable --now cockpit.socket && echo OK']], 'note' => 'Entre com o seu usuário e senha do Linux.'], ['id' => 'tools', 'name' => 'Ferramentas', 'cat' => 'sys', 'color' => '#22c55e', 'pkgs' => ['htop', 'btop', 'ncdu', 'tmux'], 'desc' => 'htop e btop (uso do sistema), ncdu (o que ocupa o disco) e tmux (terminal que não fecha). Use no terminal completo.'], ]; } function app_find(string $id): ?array { foreach (apps_catalog() as $a) if ($a['id'] === $id) return $a; return null; } /** Passos de instalar / desinstalar (para o jobs.inc.php). */ function app_job_steps(array $a, string $op): array { if ($op === 'install') { $l = $a['install'] ?? [['Atualizar lista de pacotes', APT . ' update'], ['Instalar ' . implode(', ', $a['pkgs']), APT . ' install ' . implode(' ', array_map('escapeshellarg', $a['pkgs']))]]; if (!isset($a['install']) && !empty($a['svc'])) $l[] = ['Ligar ' . $a['svc'] . ' no boot', 'systemctl enable --now ' . escapeshellarg($a['svc']) . ' && systemctl is-active ' . escapeshellarg($a['svc'])]; } else { $l = $a['remove'] ?? [['Remover ' . implode(', ', $a['pkgs']), app_rm_pkgs($a['pkgs'])]]; } $out = []; foreach ($l as $i => [$label, $cmd]) { // o job.php roda com umask 027; chaves em /usr/share/keyrings precisam ser legíveis pelo usuário _apt $out[] = ['key' => $op . $i, 'label' => $label, 'cmd' => 'umask 022; ' . $cmd, 'timeout' => 3600, 'env' => ['DEBIAN_FRONTEND' => 'noninteractive', 'COMPOSER_ALLOW_SUPERUSER' => '1']]; } return $out; } LKM_EOF_APPSINC cat > "$LIBDIR/apps.php" <<'LKM_EOF_APPS' true] + $d, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } function fail(string $m, array $extra = []): never { echo json_encode(['ok' => false, 'error' => $m] + $extra, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } function is_root(): bool { return function_exists('posix_geteuid') ? posix_geteuid() === 0 : trim((string)shell_exec('id -u')) === '0'; } function need_root(): void { if (!is_root()) fail('A App Store precisa do terminal do LK Monitor como root. No servidor: sudo lk › opção 33 › instalar / atualizar e escolha root.'); } /** Executa sem shell e devolve [código, saída]. */ function run(array $argv, int $timeout = 20): array { $p = @proc_open(array_merge(['/usr/bin/timeout', '-k', '3', (string)$timeout], $argv), [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, ['PATH' => getenv('PATH'), 'LANG' => 'C.UTF-8', 'HOME' => '/root', 'COMPOSER_ALLOW_SUPERUSER' => '1']); if (!is_resource($p)) return [127, '']; $o = (string)stream_get_contents($pipes[1]); $e = (string)stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $c = proc_close($p); return [$c, trim($o !== '' ? $o : $e)]; } function which(string $b): ?string { foreach (explode(':', (string)getenv('PATH')) as $d) if (is_file("$d/$b") && is_executable("$d/$b")) return "$d/$b"; return null; } /** A unidade do systemd existe? (o lk-cloudflared, por exemplo, só é criado depois de conectar a conta na aba Internet) */ function unit_exists(string $u): bool { if (!str_contains($u, '.')) $u .= '.service'; foreach (['/etc/systemd/system', '/usr/lib/systemd/system', '/lib/systemd/system', '/run/systemd/system'] as $d) if (is_file("$d/$u") || is_link("$d/$u")) return true; return false; } function short_ver(string $v): string { $v = preg_replace('/^\d+:/', '', trim($v)); // época do dpkg if (preg_match('/(\d+\.\d+(?:\.\d+)?)/', $v, $m)) return $m[1]; return mb_substr($v, 0, 24); } /** Tarefas da App Store ainda rodando, e a última de cada app. */ function app_jobs(): array { $files = glob(jobs_dir() . '/*.json') ?: []; rsort($files); $alive = null; $last = []; foreach (array_slice($files, 0, 80) as $f) { $J = json_decode((string)@file_get_contents($f), true); if (!is_array($J) || ($J['type'] ?? '') !== 'app') continue; $id = (string)($J['app'] ?? ''); $on = job_alive($J); if ($on && !$alive) $alive = $J; if (!isset($last[$id])) $last[$id] = ['id' => $J['id'], 'op' => $J['op'] ?? 'install', 'state' => $on ? 'running' : ($J['state'] ?? ''), 'ended' => $J['ended'] ?? null]; } return [$alive, $last]; } function apps_state(): array { $cat = apps_catalog(); // pacotes: uma chamada só $pk = []; $all = []; foreach ($cat as $a) foreach ($a['pkgs'] ?? [] as $p) $all[$p] = 1; if ($all) { [, $o] = run(array_merge(['dpkg-query', '-W', '-f=${Package} ${db:Status-Abbrev} ${Version}\n'], array_keys($all))); foreach (explode("\n", $o) as $l) { $p = preg_split('/\s+/', trim($l)); if (count($p) >= 3 && str_starts_with($p[1], 'ii')) $pk[$p[0]] = $p[2]; } } // containers $ct = []; $docker = which('docker'); if ($docker && is_root()) { [$c, $o] = run([$docker, 'ps', '-a', '--format', '{{.Names}}|{{.State}}|{{.Image}}'], 15); if ($c === 0) foreach (explode("\n", $o) as $l) { $p = explode('|', trim($l)); if (count($p) === 3) $ct[$p[0]] = ['state' => $p[1], 'image' => $p[2]]; } } // serviços: uma chamada só $svcs = array_values(array_unique(array_filter(array_map(fn($a) => $a['svc'] ?? null, $cat)))); $st = []; if ($svcs) { [, $o] = run(array_merge(['systemctl', 'is-active'], $svcs)); foreach (explode("\n", $o) as $i => $l) $st[$svcs[$i]] = trim($l) ?: 'unknown'; } [$alive, $last] = app_jobs(); $out = []; $inst = []; foreach ($cat as $a) { $ver = null; if (isset($a['docker'])) { $c = $ct[$a['docker']] ?? null; $ok = $c !== null; if ($ok) $ver = preg_replace('#^.*/#', '', $c['image']); $state = $c ? ($c['state'] === 'running' ? 'active' : $c['state']) : null; } else { if (!empty($a['pkgs'])) { $ok = !array_diff($a['pkgs'], array_keys($pk)); if ($ok) $ver = short_ver($pk[$a['pkgs'][0]]); } elseif (!empty($a['bin'])) { $ok = which($a['bin']) !== null; if ($ok && !empty($a['ver'])) { [, $v] = run(['/bin/sh', '-c', $a['ver']], 8); $ver = $v !== '' ? short_ver($v) : null; } } else { $ok = is_file((string)($a['file'] ?? '')); } $state = $ok && !empty($a['svc']) && unit_exists($a['svc']) ? ($st[$a['svc']] ?? 'unknown') : null; } // a página do WhatsApp fica numa porta própria (8089 ou a próxima livre) if ($a['id'] === 'whatsapp' && preg_match('/^Listen\s+(\d+)/m', (string)@file_get_contents('/etc/apache2/sites-available/whatsapp-panel.conf'), $m)) $a['port'] = (int)$m[1]; if ($a['id'] === 'whatsapp' && !$ok && !is_file('/usr/local/share/lk/install-whatsapp.sh')) $a['note'] = 'Atualize o LK (install-lk.sh mais recente) para instalar o WhatsApp pela loja.'; $inst[$a['id']] = $ok; $out[] = [ 'id' => $a['id'], 'name' => $a['name'], 'cat' => $a['cat'], 'desc' => $a['desc'], 'color' => $a['color'] ?? '#6b7280', 'installed' => $ok, 'version' => $ver, 'state' => $state, 'svc' => $state !== null ? ($a['svc'] ?? null) : null, 'docker' => isset($a['docker']), 'port' => $a['port'] ?? null, 'path' => $a['path'] ?? null, 'https' => !empty($a['https']), 'needs' => $a['needs'] ?? null, 'protect' => $a['protect'] ?? null, 'note' => $a['note'] ?? null, 'job' => $last[$a['id']] ?? null, ]; } foreach ($out as &$o) $o['needs_ok'] = !$o['needs'] || !empty($inst[$o['needs']]); return ['apps' => $out, 'cats' => APP_CATS, 'busy' => $alive ? ['id' => $alive['id'], 'app' => $alive['app'] ?? '', 'title' => $alive['title'] ?? ''] : null, 'root' => is_root()]; } /* ------------------------------------------------------------------ */ $req = json_decode((string)stream_get_contents(STDIN), true); if (!is_array($req)) fail('Pedido inválido'); $act = (string)($req['action'] ?? ''); if ($act === 'list') out(apps_state()); need_root(); $a = app_find((string)($req['id'] ?? '')); if (!$a) fail('App desconhecido'); $S = null; foreach (apps_state()['apps'] as $x) if ($x['id'] === $a['id']) $S = $x; switch ($act) { case 'install': case 'remove': [$alive] = app_jobs(); if ($alive) fail('Espere terminar: ' . ($alive['title'] ?? 'outra instalação') . '.', ['running' => $alive['id']]); if ($act === 'install') { if ($S['installed']) fail($a['name'] . ' já está instalado.'); if (!$S['needs_ok']) { $n = app_find((string)$a['needs']); fail('Instale antes: ' . ($n['name'] ?? $a['needs']) . '.'); } } else { if (!empty($a['protect'])) fail($a['protect']); if (!$S['installed']) fail($a['name'] . ' não está instalado.'); } $J = job_start('app', ($act === 'install' ? 'Instalar ' : 'Desinstalar ') . $a['name'], ['app' => $a['id'], 'op' => $act]); out(['job' => $J['id']]); case 'svc': $op = (string)($req['op'] ?? ''); if (!in_array($op, ['start', 'stop', 'restart'], true)) fail('Ação inválida'); if (!$S['installed']) fail($a['name'] . ' não está instalado.'); if (isset($a['docker'])) [$c, $o] = run(['docker', $op, $a['docker']], 120); elseif (!empty($a['svc']) && unit_exists($a['svc'])) [$c, $o] = run(['systemctl', $op, $a['svc']], 120); elseif ($a['id'] === 'cloudflared') fail('O túnel ainda não foi criado. Conecte a conta da Cloudflare na aba Internet (passo 2).'); else fail($a['name'] . ' não tem serviço para ' . $op . '.'); if ($c !== 0) fail('Falha: ' . ($o ?: 'código ' . $c)); out(['msg' => $a['name'] . ' ' . ['start' => 'iniciado', 'stop' => 'parado', 'restart' => 'reiniciado'][$op]]); } fail('Ação inválida'); LKM_EOF_APPS cat > "$LIBDIR/cf.php" <<'LKM_EOF_CF' cert.pem em /root/.cloudflared), * o painel cria um túnel só dele e guarda tudo em /etc/lk/cloudflared (fora das pastas padrão do cloudflared, para o * "teste rápido" continuar funcionando). Serviço: lk-cloudflared. Teste rápido (trycloudflare.com): unidade lk-cf-quick. * * status | login | setup | route_add | route_del | toggle | quick_start | quick_stop */ declare(strict_types=1); if (PHP_SAPI !== 'cli') { http_response_code(404); exit; } error_reporting(E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED); ini_set('display_errors', 'stderr'); umask(077); set_time_limit(0); putenv('PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'); putenv('HOME=/root'); const CFD = '/etc/lk/cloudflared'; const CERT = '/root/.cloudflared/cert.pem'; const UNIT = 'lk-cloudflared'; const QUNIT = 'lk-cf-quick'; const LOGIN_LOG = '/run/lk-cf-login.log'; const QUICK_LOG = '/run/lk-cf-quick.log'; const PANEL_SITE = '/etc/apache2/sites-available/lkmonitor.conf'; const HOST_RE = '/^(?=.{4,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/'; function out(array $d = []): never { echo json_encode(['ok' => true] + $d, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } function fail(string $m, array $extra = []): never { echo json_encode(['ok' => false, 'error' => $m] + $extra, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit(0); } function run(array $argv, int $timeout = 60): array { $p = @proc_open(array_merge(['/usr/bin/timeout', '-k', '5', (string)$timeout], $argv), [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['redirect', 1]], $pipes, null, ['PATH' => getenv('PATH'), 'HOME' => '/root', 'LANG' => 'C.UTF-8', 'TERM' => 'dumb', 'NO_COLOR' => '1']); if (!is_resource($p)) return [127, 'Não foi possível executar ' . $argv[0]]; $o = (string)stream_get_contents($pipes[1]); fclose($pipes[1]); $c = proc_close($p); return [$c, trim((string)preg_replace('/\e\[[0-9;]*m/', '', $o))]; } function bin(): ?string { foreach (['/usr/bin/cloudflared', '/usr/local/bin/cloudflared'] as $b) if (is_executable($b)) return $b; return null; } function need_bin(): string { return bin() ?? fail('O cloudflared não está instalado. Instale pela App Store (Cloudflare Tunnel).', ['nobin' => true]); } function need_root(): void { if (function_exists('posix_geteuid') && posix_geteuid() !== 0) fail('A aba Internet precisa do terminal do LK Monitor como root (sudo lk › 33).'); } /** Agenda um comando para daqui a $sec segundos (a resposta chega antes, mesmo se o painel estiver passando pelo túnel). */ function later(array $argv, int $sec = 2): void { run(array_merge(['systemd-run', '--quiet', '--collect', '--unit=lk-cf-act-' . bin2hex(random_bytes(3)), '--on-active=' . $sec, '--timer-property=AccuracySec=100ms'], $argv), 20); } function jread(string $f, array $def = []): array { $d = json_decode((string)@file_get_contents(CFD . '/' . $f), true); return is_array($d) ? $d : $def; } function jwrite(string $f, array $d): void { if (!is_dir(CFD)) mkdir(CFD, 0700, true); $p = CFD . '/' . $f; file_put_contents($p . '.tmp', json_encode($d, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); rename($p . '.tmp', $p); } function active(string $unit): string { [, $o] = run(['systemctl', 'is-active', $unit], 10); return $o !== '' ? strtok($o, "\n") : 'unknown'; } /** Domínio (zona) ligado na conta: lido do cert.pem e confirmado na API da Cloudflare (guardado em zone.json). */ function zone(): ?array { if (!is_file(CERT)) return null; $cache = jread('zone.json'); $raw = (string)@file_get_contents(CERT); $hash = md5($raw); // guarda também o "não descobri" por 10 min, para não chamar a API a cada atualização da tela if (($cache['hash'] ?? '') === $hash && (!empty($cache['name']) || time() - (int)($cache['at'] ?? 0) < 600)) return $cache; $tok = null; if (preg_match('/-----BEGIN ARGO TUNNEL TOKEN-----(.*?)-----END ARGO TUNNEL TOKEN-----/s', $raw, $m)) $tok = json_decode((string)base64_decode(preg_replace('/\s+/', '', $m[1])), true); if (!is_array($tok) || empty($tok['zoneID'])) { jwrite('zone.json', ['hash' => $hash, 'name' => null, 'at' => time()]); return ['hash' => $hash, 'name' => null]; } $z = ['hash' => $hash, 'id' => $tok['zoneID'], 'name' => null, 'at' => time()]; if (!empty($tok['apiToken']) && function_exists('curl_init')) { $c = curl_init('https://api.cloudflare.com/client/v4/zones/' . rawurlencode($tok['zoneID'])); curl_setopt_array($c, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $tok['apiToken']]]); $r = json_decode((string)curl_exec($c), true); curl_close($c); if (!empty($r['result']['name'])) $z['name'] = $r['result']['name']; } jwrite('zone.json', $z); return $z; } /** Porta HTTP do painel (a linha "Listen N" sem https no site do Apache). */ function panel_port(): int { $s = (string)@file_get_contents(PANEL_SITE); return preg_match('/^Listen\s+(\d+)\s*$/m', $s, $m) ? (int)$m[1] : 8088; } /** Sites do Apache: nome do arquivo, domínio principal, apelidos e se tem HTTPS (porta 443) próprio. */ function apache_sites(): array { $out = []; foreach (glob('/etc/apache2/sites-enabled/*.conf') ?: [] as $f) { $n = basename($f, '.conf'); if (in_array($n, ['lkmonitor', '000-default', 'default-ssl'], true) || str_ends_with($n, '-le-ssl')) continue; $c = (string)@file_get_contents($f); if (!preg_match('/^\s*ServerName\s+(\S+)/mi', $c, $m)) continue; $dom = strtolower($m[1]); $alias = preg_match_all('/^\s*ServerAlias\s+(.+)$/mi', $c, $mm) ? preg_split('/\s+/', strtolower(trim(implode(' ', $mm[1])))) : []; $ssl = false; foreach (glob('/etc/apache2/sites-enabled/*.conf') ?: [] as $g) { $t = (string)@file_get_contents($g); if (preg_match('/]*:443>/i', $t) && preg_match('/^\s*Server(Name|Alias)\s+(.*\s)?' . preg_quote($dom, '/') . '(\s|$)/mi', $t)) { $ssl = true; break; } } $out[] = ['name' => $n, 'domain' => $dom, 'alias' => array_values(array_filter($alias)), 'ssl' => $ssl]; } usort($out, fn($a, $b) => strcmp($a['domain'], $b['domain'])); return $out; } /** Para onde o endereço aponta: [service, originRequest|null, rótulo]. */ function target_service(array $t, string $host): array { $k = (string)($t['type'] ?? ''); if ($k === 'panel') return ['http://localhost:' . panel_port(), null, 'Painel LK']; if ($k === 'site') { foreach (apache_sites() as $s) if ($s['name'] === ($t['name'] ?? '')) { // site com HTTPS próprio (Certbot): vai direto no 443, senão o redirecionamento 80 -> 443 entra em laço return $s['ssl'] ? ['https://localhost:443', ['noTLSVerify' => true, 'originServerName' => $host], 'Site ' . $s['domain']] : ['http://localhost:80', null, 'Site ' . $s['domain']]; } fail('Site não encontrado no Apache: ' . ($t['name'] ?? '')); } if ($k === 'port') { $p = (int)($t['port'] ?? 0); if ($p < 1 || $p > 65535) fail('Porta inválida'); $https = !empty($t['https']); return [($https ? 'https' : 'http') . '://localhost:' . $p, $https ? ['noTLSVerify' => true] : null, ($t['label'] ?? '') !== '' ? (string)$t['label'] : 'Porta ' . $p]; } fail('Destino inválido'); } function yq(string $s): string { return '"' . addcslashes($s, "\"\\") . '"'; } /** Gera o config.yml a partir de routes.json (sempre termina com a regra "pega tudo" 404). */ function write_config(): void { $T = jread('tunnel.json'); if (empty($T['id'])) fail('Túnel ainda não criado.'); $y = "# Gerado pelo LK Monitor (aba Internet). Mudanças feitas à mão aqui são perdidas.\n"; $y .= 'tunnel: ' . $T['id'] . "\ncredentials-file: " . CFD . "/creds.json\nno-autoupdate: true\ningress:\n"; foreach (jread('routes.json') as $r) { $y .= ' - hostname: ' . yq($r['host']) . "\n service: " . yq($r['service']) . "\n"; if (!empty($r['origin'])) { $y .= " originRequest:\n"; foreach ($r['origin'] as $k => $v) $y .= ' ' . $k . ': ' . (is_bool($v) ? ($v ? 'true' : 'false') : yq((string)$v)) . "\n"; } } $y .= " - service: http_status:404\n"; file_put_contents(CFD . '/config.yml.new', $y); [$c, $o] = run([need_bin(), 'tunnel', '--config', CFD . '/config.yml.new', 'ingress', 'validate'], 30); if ($c !== 0) { @unlink(CFD . '/config.yml.new'); fail("Configuração inválida:\n" . $o); } rename(CFD . '/config.yml.new', CFD . '/config.yml'); } function write_unit(): void { $u = "[Unit]\nDescription=Cloudflare Tunnel do LK Monitor\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\n" . 'ExecStart=' . need_bin() . ' --no-autoupdate --config ' . CFD . "/config.yml tunnel run\nRestart=on-failure\nRestartSec=5\nTimeoutStartSec=0\n\n[Install]\nWantedBy=multi-user.target\n"; file_put_contents('/etc/systemd/system/' . UNIT . '.service', $u); chmod('/etc/systemd/system/' . UNIT . '.service', 0644); run(['systemctl', 'daemon-reload'], 30); } /** IP real dos visitantes no Apache (senão tudo que vem pelo túnel aparece como 127.0.0.1). */ function apache_remoteip(): void { $f = '/etc/apache2/conf-available/lk-cloudflare.conf'; if (!is_dir('/etc/apache2/conf-available')) return; $c = "# LK Monitor: IP real de quem chega pelo Cloudflare Tunnel (o cabeçalho só vale vindo do próprio servidor)\n" . "\n RemoteIPHeader CF-Connecting-IP\n RemoteIPInternalProxy 127.0.0.1 ::1\n\n"; if (@file_get_contents($f) === $c && is_link('/etc/apache2/conf-enabled/lk-cloudflare.conf')) return; file_put_contents($f, $c); chmod($f, 0644); run(['a2enmod', '-q', 'remoteip'], 30); run(['a2enconf', '-q', 'lk-cloudflare'], 30); [$ok] = run(['apache2ctl', 'configtest'], 30); if ($ok === 0) later(['/bin/systemctl', 'reload', 'apache2'], 2); } function quick_url(): ?string { return preg_match('#https://[a-z0-9-]+\.trycloudflare\.com#', (string)@file_get_contents(QUICK_LOG), $m) ? $m[0] : null; } function status(): array { $b = bin(); $ver = null; if ($b) { [, $v] = run([$b, '--version'], 10); $ver = preg_match('/(\d{4}\.\d+\.\d+)/', $v, $m) ? $m[1] : null; } $T = jread('tunnel.json'); $loginLog = (string)@file_get_contents(LOGIN_LOG); $Q = jread('quick.json'); $qa = active(QUNIT); return [ 'installed' => $b !== null, 'version' => $ver, 'cert' => is_file(CERT), 'zone' => is_file(CERT) ? (zone()['name'] ?? null) : null, 'login' => ['running' => active('lk-cf-login') === 'active', 'url' => preg_match('#https://dash\.cloudflare\.com/argotunnel\S+#', $loginLog, $m) ? $m[0] : null], 'tunnel' => $T ? ['id' => $T['id'] ?? null, 'name' => $T['name'] ?? null] : null, 'service' => is_file('/etc/systemd/system/' . UNIT . '.service') ? active(UNIT) : null, 'routes' => array_map(fn($r) => ['host' => $r['host'], 'label' => $r['label'], 'service' => $r['service'], 'target' => $r['target']], jread('routes.json')), 'quick' => ['active' => $qa === 'active' || $qa === 'activating', 'url' => quick_url(), 'label' => $Q['label'] ?? null], 'sites' => apache_sites(), 'panel_port' => panel_port(), ]; } /* ------------------------------------------------------------------ */ $req = json_decode((string)stream_get_contents(STDIN), true); if (!is_array($req)) fail('Pedido inválido'); $act = (string)($req['action'] ?? ''); if ($act === 'status') out(status()); need_root(); switch ($act) { case 'login': { need_bin(); if (is_file(CERT)) out(['done' => true]); if (active('lk-cf-login') !== 'active') { @unlink(LOGIN_LOG); run(['systemctl', 'reset-failed', 'lk-cf-login'], 10); [$c, $o] = run(['systemd-run', '--quiet', '--collect', '--unit=lk-cf-login', '--setenv=HOME=/root', '--property=RuntimeMaxSec=900', '--property=StandardOutput=file:' . LOGIN_LOG, '--property=StandardError=file:' . LOGIN_LOG, need_bin(), 'tunnel', 'login'], 20); if ($c !== 0) fail('Não foi possível iniciar o login: ' . $o); } for ($i = 0; $i < 20; $i++) { // espera o link aparecer (até ~10 s) if (preg_match('#https://dash\.cloudflare\.com/argotunnel\S+#', (string)@file_get_contents(LOGIN_LOG), $m)) out(['url' => $m[0]]); usleep(500000); } fail("O cloudflared não mostrou o link de login. Saída:\n" . trim((string)@file_get_contents(LOGIN_LOG))); } case 'setup': { $b = need_bin(); if (!is_file(CERT)) fail('Conecte a conta da Cloudflare primeiro.'); if (!is_dir(CFD)) mkdir(CFD, 0700, true); $T = jread('tunnel.json'); if (empty($T['id']) || !is_file(CFD . '/creds.json')) { $name = 'lk-' . substr(preg_replace('/[^a-z0-9]+/', '-', strtolower((string)gethostname())), 0, 30) . '-' . bin2hex(random_bytes(2)); [$c, $o] = run([$b, 'tunnel', '--origincert', CERT, 'create', '--credentials-file', CFD . '/creds.json', $name], 60); if ($c !== 0 || !preg_match('/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/', $o, $m)) fail("Não foi possível criar o túnel:\n" . $o); $T = ['id' => $m[0], 'name' => $name, 'created' => time()]; jwrite('tunnel.json', $T); if (!is_file(CFD . '/routes.json')) jwrite('routes.json', []); } write_config(); write_unit(); apache_remoteip(); run(['systemctl', 'enable', UNIT], 30); later(['/bin/systemctl', 'restart', UNIT], 1); out(['msg' => 'Túnel ' . $T['name'] . ' pronto.', 'zone' => zone()['name'] ?? null]); } case 'route_add': { $b = need_bin(); $T = jread('tunnel.json'); if (empty($T['id'])) fail('Conecte a conta e crie o túnel primeiro.'); $host = strtolower(trim((string)($req['host'] ?? ''))); if (!preg_match(HOST_RE, $host)) fail('Endereço inválido. Exemplo: painel.seudominio.com'); $z = zone()['name'] ?? null; if ($z && $host !== $z && !str_ends_with($host, '.' . $z)) fail("A conta foi ligada ao domínio $z. Use $z ou um endereço terminado em .$z."); $routes = jread('routes.json'); foreach ($routes as $r) if ($r['host'] === $host) fail("$host já está publicado."); [$service, $origin, $label] = target_service((array)($req['target'] ?? []), $host); $args = [$b, 'tunnel', '--origincert', CERT, 'route', 'dns']; if (!empty($req['overwrite'])) $args[] = '--overwrite-dns'; [$c, $o] = run(array_merge($args, [$T['id'], $host]), 60); if ($c !== 0) { if (stripos($o, 'already exists') !== false) fail("Já existe um registro DNS para $host na Cloudflare (talvez apontando para outro lugar).", ['exists' => true]); fail("A Cloudflare recusou o endereço $host:\n" . $o); } $routes[] = ['host' => $host, 'service' => $service, 'origin' => $origin, 'label' => $label, 'target' => (array)$req['target'], 'added' => time()]; jwrite('routes.json', $routes); write_config(); later(['/bin/systemctl', 'restart', UNIT], 2); out(['msg' => "$host publicado. Pode levar 1 ou 2 minutos para abrir pela primeira vez.", 'url' => 'https://' . $host]); } case 'route_del': { $host = strtolower(trim((string)($req['host'] ?? ''))); $routes = jread('routes.json'); $left = array_values(array_filter($routes, fn($r) => $r['host'] !== $host)); if (count($left) === count($routes)) fail('Endereço não encontrado.'); jwrite('routes.json', $left); write_config(); later(['/bin/systemctl', 'restart', UNIT], 2); out(['msg' => "$host saiu do ar. O registro DNS continua na Cloudflare (mostra erro 404) até você apagar lá."]); } case 'toggle': { if (!is_file('/etc/systemd/system/' . UNIT . '.service')) fail('Túnel ainda não configurado.'); $on = !empty($req['on']); run(['systemctl', $on ? 'enable' : 'disable', UNIT], 30); later(['/bin/systemctl', $on ? 'restart' : 'stop', UNIT], 2); out(['msg' => $on ? 'Túnel ligando…' : 'Túnel desligado. Os endereços publicados saem do ar.']); } case 'quick_start': { $b = need_bin(); $t = (array)($req['target'] ?? []); $host = ''; if (($t['type'] ?? '') === 'site') foreach (apache_sites() as $s) if ($s['name'] === ($t['name'] ?? '')) $host = $s['domain']; [$service, $origin, $label] = target_service($t, $host !== '' ? $host : 'localhost'); run(['systemctl', 'stop', QUNIT], 20); run(['systemctl', 'reset-failed', QUNIT], 10); @unlink(QUICK_LOG); $args = [$b, 'tunnel', '--no-autoupdate', '--url', $service]; if ($host !== '') array_push($args, '--http-host-header', $host); if (!empty($origin['noTLSVerify'])) $args[] = '--no-tls-verify'; if (!empty($origin['originServerName'])) array_push($args, '--origin-server-name', $origin['originServerName']); [$c, $o] = run(array_merge(['systemd-run', '--quiet', '--collect', '--unit=' . QUNIT, '--setenv=HOME=/tmp', '--property=RuntimeMaxSec=21600', '--property=StandardOutput=file:' . QUICK_LOG, '--property=StandardError=file:' . QUICK_LOG], $args), 20); if ($c !== 0) fail('Não foi possível iniciar o teste: ' . $o); jwrite('quick.json', ['label' => $label, 'started' => time()]); for ($i = 0; $i < 40; $i++) { // o endereço aparece em uns 5-15 s if ($u = quick_url()) out(['url' => $u, 'label' => $label]); usleep(500000); } out(['url' => null, 'label' => $label, 'msg' => 'Ainda gerando o endereço…']); } case 'quick_stop': run(['systemctl', 'stop', QUNIT], 20); @unlink(QUICK_LOG); out(['msg' => 'Teste rápido desligado.']); } fail('Ação inválida'); LKM_EOF_CF cat > "$LIBDIR/term.py" <<'LKM_EOF_TERMPY' #!/usr/bin/env python3 """ LK Monitor - terminal web (PTY + WebSocket). Roda como o usuário lkmonitor (serviço lkmonitor-term), escutando só em 127.0.0.1. O Apache encaminha /term-ws do painel para cá. Cada conexão precisa de um ticket de uso único criado pelo painel (index.php, depois do login) em LKM_DATA/term/t-.json, válido por 60 s. Cada aba do terminal é uma sessão: um shell de login aberto com "sudo -n -u -i", a mesma permissão do terminal antigo. A sessão continua viva por LKM_TERM_TTL segundos (padrão 30 min) depois que o navegador fecha ou cai, e volta com a tela como estava ao reconectar. Protocolo (cliente -> servidor, texto): "i", "r,", "k" (fechar a sessão). Servidor -> cliente: binário = saída do terminal; texto = JSON de controle (hello, exit, kicked, gone, error). """ import asyncio import base64 import fcntl import hashlib import json import os import pty import re import secrets import signal import struct import sys import termios import time from urllib.parse import parse_qs, urlsplit DATA = os.environ.get('LKM_DATA', '/var/lib/lkmonitor') TDIR = os.path.join(DATA, 'term') CONFIG = os.environ.get('LKM_CONFIG', '/var/www/lkmonitor/config.php') CMDLOG = os.path.join(DATA, 'cmd.log') TTL = max(60, int(os.environ.get('LKM_TERM_TTL', '1800') or 1800)) TEST_SHELL = os.environ.get('LKM_TERM_TEST_SHELL', '') # só para testes: abre este shell em vez do sudo MAX_SESSIONS = 8 BUF_MAX = 512 * 1024 # tela guardada para quando o navegador reconectar MAX_MSG = 1024 * 1024 # maior mensagem aceita do navegador PAUSE_AT = 2 * 1024 * 1024 # navegador lento: para de ler o terminal até esvaziar GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' SESSIONS = {} def log(msg): sys.stderr.write(time.strftime('[%H:%M:%S] ') + msg + '\n') sys.stderr.flush() def cmdlog(ip, msg): try: with open(CMDLOG, 'a', encoding='utf-8') as f: f.write('%s %s [terminal] %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), ip or '?', msg)) except OSError: pass def shell_user(): """Usuário do terminal, lido do config.php do painel (o mesmo do terminal simples).""" try: with open(CONFIG, encoding='utf-8') as f: m = re.search(r"define\('LKM_SHELL_USER', '([^']*)'\)", f.read()) except OSError: return '' u = m.group(1) if m else '' return u if re.fullmatch(r'[a-z_][a-z0-9_.-]{0,31}\$?', u) else '' def save_state(): """Lista das sessões para o painel (index.php ?term=list).""" now = time.time() lst = [{'id': s.sid, 'created': int(s.created), 'last': int(s.last), 'attached': s.client is not None, 'idle': 0 if s.client else int(now - s.detached)} for s in SESSIONS.values()] tmp = os.path.join(TDIR, '.sessions.%d.tmp' % os.getpid()) try: with open(tmp, 'w') as f: json.dump({'pid': os.getpid(), 'ttl': TTL, 'sessions': lst}, f) os.chmod(tmp, 0o600) os.replace(tmp, os.path.join(TDIR, 'sessions.json')) except OSError: pass def take_ticket(t): """Lê e apaga o ticket (uso único). Devolve o conteúdo ou None.""" if not re.fullmatch(r'[a-f0-9]{32}', t or ''): return None p = os.path.join(TDIR, 't-%s.json' % t) try: with open(p) as f: d = json.load(f) except (OSError, ValueError): return None finally: try: os.unlink(p) except OSError: pass if not isinstance(d, dict) or float(d.get('exp', 0)) < time.time(): return None return d def clamp(v, lo, hi, dflt): try: return max(lo, min(hi, int(v))) except (TypeError, ValueError): return dflt # --------------------------------------------------------------------------- WebSocket class Client: def __init__(self, reader, writer, ip): self.r = reader self.w = writer self.ip = ip self.closed = False def frame(self, op, payload=b''): if self.closed: return n = len(payload) if n < 126: hdr = struct.pack('!BB', 0x80 | op, n) elif n < 65536: hdr = struct.pack('!BBH', 0x80 | op, 126, n) else: hdr = struct.pack('!BBQ', 0x80 | op, 127, n) try: self.w.write(hdr + payload) except (ConnectionError, RuntimeError): self.closed = True def send_bin(self, b): self.frame(0x2, bytes(b)) def send_json(self, o): self.frame(0x1, json.dumps(o, ensure_ascii=False).encode('utf-8')) def backlog(self): tr = self.w.transport return tr.get_write_buffer_size() if tr and not tr.is_closing() else 0 async def drain(self): try: await self.w.drain() except (ConnectionError, RuntimeError): self.closed = True def close(self, code=1000): if self.closed: return self.frame(0x8, struct.pack('!H', code)) self.closed = True try: self.w.close() except (ConnectionError, RuntimeError): pass async def recv(self): """Próxima mensagem completa: (opcode, bytes). None quando a conexão fecha.""" parts = [] first_op = None while True: try: b1, b2 = await self.r.readexactly(2) n = b2 & 0x7F if n == 126: n = struct.unpack('!H', await self.r.readexactly(2))[0] elif n == 127: n = struct.unpack('!Q', await self.r.readexactly(8))[0] if n > MAX_MSG or not (b2 & 0x80): # cliente sempre manda mascarado self.close(1009 if n > MAX_MSG else 1002) return None mask = await self.r.readexactly(4) data = await self.r.readexactly(n) except (asyncio.IncompleteReadError, ConnectionError, OSError): return None if n: data = bytes(a ^ mask[i & 3] for i, a in enumerate(data)) if n < 64 else _unmask(data, mask) op = b1 & 0x0F fin = b1 & 0x80 if op == 0x8: self.close() return None if op == 0x9: self.frame(0xA, data) continue if op == 0xA: continue if op in (0x1, 0x2): first_op = op parts = [data] elif op == 0x0 and first_op is not None: parts.append(data) if sum(len(p) for p in parts) > MAX_MSG: self.close(1009) return None else: self.close(1002) return None if fin: return first_op, b''.join(parts) def _unmask(data, mask): n = len(data) m = (mask * (n // 4 + 1))[:n] return (int.from_bytes(data, 'big') ^ int.from_bytes(m, 'big')).to_bytes(n, 'big') # --------------------------------------------------------------------------- sessões class Session: def __init__(self, user, cols, rows): self.sid = secrets.token_hex(16) self.user = user self.created = self.last = self.detached = time.time() self.buf = bytearray() self.client = None self.alive = True self.reading = False self.wq = bytearray() self.code = None loop = asyncio.get_running_loop() pid, fd = pty.fork() if pid == 0: # filho: vira o shell try: fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0)) env = {'TERM': 'xterm-256color', 'COLORTERM': 'truecolor', 'PATH': PATH, 'LANG': os.environ.get('LANG') or 'C.UTF-8'} if TEST_SHELL: env['HOME'] = os.environ.get('HOME', '/') os.execve(TEST_SHELL, [TEST_SHELL, '-l'], env) os.execve('/usr/bin/sudo', ['sudo', '-n', '-u', user, '-i'], env) except Exception: pass os._exit(127) self.pid = pid self.fd = fd os.set_blocking(fd, False) self.loop = loop self._read_on() def _read_on(self): if self.alive and not self.reading: self.loop.add_reader(self.fd, self.on_read) self.reading = True def _read_off(self): if self.reading: self.loop.remove_reader(self.fd) self.reading = False def on_read(self): try: data = os.read(self.fd, 65536) except (BlockingIOError, InterruptedError): return except OSError: data = b'' if not data: self.ended() return self.last = time.time() self.buf += data if len(self.buf) > BUF_MAX: cut = len(self.buf) - BUF_MAX nl = self.buf.find(b'\n', cut, cut + 8192) # corta numa quebra de linha del self.buf[:nl + 1 if nl >= 0 else cut] c = self.client if c: c.send_bin(data) if c.backlog() > PAUSE_AT: self._read_off() asyncio.ensure_future(self._resume(c)) async def _resume(self, c): try: await c.drain() finally: self._read_on() def write(self, data): if not self.alive or not data: return self.last = time.time() self.wq += data self._flush() def _flush(self): try: while self.wq: n = os.write(self.fd, self.wq[:65536]) del self.wq[:n] except (BlockingIOError, InterruptedError): self.loop.add_writer(self.fd, self._flush_ready) return except OSError: self.wq.clear() self.loop.remove_writer(self.fd) def _flush_ready(self): self.loop.remove_writer(self.fd) self._flush() def resize(self, cols, rows): if self.alive: try: fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0)) except OSError: pass def attach(self, c, cols, rows): old = self.client if old is not None and old is not c: old.send_json({'t': 'kicked'}) old.close(4001) self.client = c self.resize(cols, rows) c.send_json({'t': 'hello', 'sid': self.sid, 'user': self.user, 'ttl': TTL}) for i in range(0, len(self.buf), 65536): c.send_bin(self.buf[i:i + 65536]) save_state() def detach(self, c): if self.client is c: self.client = None self.detached = time.time() save_state() def reap(self): if self.code is not None: return True try: pid, st = os.waitpid(self.pid, os.WNOHANG) except ChildProcessError: self.code = -1 return True if pid == 0: return False self.code = os.waitstatus_to_exitcode(st) if hasattr(os, 'waitstatus_to_exitcode') else (st >> 8) return True def ended(self): if not self.alive: return self.alive = False self._read_off() try: self.loop.remove_writer(self.fd) except (ValueError, OSError): pass try: os.close(self.fd) except OSError: pass for _ in range(20): if self.reap(): break time.sleep(0.01) c = self.client if c: c.send_json({'t': 'exit', 'code': self.code}) c.close() SESSIONS.pop(self.sid, None) save_state() cmdlog(c.ip if c else '', 'sessão %s encerrada' % self.sid[:6]) def kill(self): if not self.alive: return for sig in (signal.SIGHUP, signal.SIGCONT): try: os.kill(self.pid, sig) except OSError: pass def force(): if self.alive and not self.reap(): try: os.kill(self.pid, signal.SIGKILL) except OSError: pass if self.alive and self.reap(): self.ended() self.loop.call_later(3, force) # --------------------------------------------------------------------------- conexões def http_error(writer, code, text): body = text.encode('utf-8') try: writer.write(('HTTP/1.1 %d %s\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: %d\r\n' 'Connection: close\r\n\r\n' % (code, {400: 'Bad Request', 403: 'Forbidden'}.get(code, 'Error'), len(body))).encode() + body) writer.close() except (ConnectionError, RuntimeError): pass async def pinger(c): while not c.closed: await asyncio.sleep(25) c.frame(0x9, b'lk') async def handle(reader, writer): try: head = await asyncio.wait_for(reader.readuntil(b'\r\n\r\n'), 10) except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError, ConnectionError, OSError): writer.close() return lines = head.decode('latin-1').split('\r\n') try: method, target, _ = lines[0].split(' ', 2) except ValueError: return http_error(writer, 400, 'requisição inválida') hd = {} for ln in lines[1:]: if ':' in ln: k, v = ln.split(':', 1) hd[k.strip().lower()] = v.strip() u = urlsplit(target) key = hd.get('sec-websocket-key', '') if method != 'GET' or not u.path.endswith('/term-ws') or 'websocket' not in hd.get('upgrade', '').lower() or not key: return http_error(writer, 400, 'use WebSocket') # a página precisa ser do próprio painel (o Apache informa o host original em X-Forwarded-Host) origin = hd.get('origin', '') host = (hd.get('x-forwarded-host') or hd.get('host') or '').split(',')[0].strip().lower() if origin and host and urlsplit(origin).netloc.lower() != host: return http_error(writer, 403, 'origem não permitida') tk = take_ticket(parse_qs(u.query).get('t', [''])[0]) if not tk: return http_error(writer, 403, 'ticket inválido ou vencido') peer = writer.get_extra_info('peername') ip = (hd.get('x-forwarded-for') or (peer[0] if peer else '?')).split(',')[0].strip() accept = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode() writer.write(('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' 'Sec-WebSocket-Accept: %s\r\n\r\n' % accept).encode()) c = Client(reader, writer, ip) cols = clamp(tk.get('cols'), 10, 500, 100) rows = clamp(tk.get('rows'), 4, 300, 30) sid = str(tk.get('sid') or '') if sid: s = SESSIONS.get(sid) if not s or not s.alive: c.send_json({'t': 'gone'}) c.close() return else: user = '' if TEST_SHELL else shell_user() if not user and not TEST_SHELL: c.send_json({'t': 'error', 'msg': 'Terminal desativado na instalação do LK Monitor.'}) c.close() return if len(SESSIONS) >= MAX_SESSIONS: c.send_json({'t': 'error', 'msg': 'Limite de %d terminais abertos. Feche algum antes de abrir outro.' % MAX_SESSIONS}) c.close() return try: s = Session(user or os.environ.get('USER', 'teste'), cols, rows) except OSError as e: c.send_json({'t': 'error', 'msg': 'Não foi possível abrir o terminal: %s' % e}) c.close() return SESSIONS[s.sid] = s cmdlog(ip, 'sessão %s aberta (%s)' % (s.sid[:6], s.user)) s.attach(c, cols, rows) ping = asyncio.ensure_future(pinger(c)) try: while True: m = await c.recv() if m is None: break op, data = m if op == 0x2: s.write(data) continue if not data: continue k = data[:1] if k == b'i': s.write(data[1:]) elif k == b'r': try: a, b = data[1:].decode().split(',') s.resize(clamp(a, 10, 500, 100), clamp(b, 4, 300, 30)) except ValueError: pass elif k == b'k': s.kill() if not s.alive: break finally: ping.cancel() s.detach(c) c.close() async def janitor(): """Fecha sessões esquecidas e recolhe processos que terminaram.""" while True: await asyncio.sleep(15) now = time.time() for s in list(SESSIONS.values()): if s.client is None and now - s.detached > TTL: log('sessão %s sem uso há %d min: fechando' % (s.sid[:6], TTL // 60)) s.kill() elif s.reap(): s.ended() try: while True: pid, _ = os.waitpid(-1, os.WNOHANG) if pid == 0: break except ChildProcessError: pass # tickets que ninguém usou try: for n in os.listdir(TDIR): p = os.path.join(TDIR, n) if n.startswith('t-') and now - os.path.getmtime(p) > 300: os.unlink(p) except OSError: pass async def main(): port = 7681 args = sys.argv[1:] if '--port' in args: port = int(args[args.index('--port') + 1]) os.makedirs(TDIR, mode=0o700, exist_ok=True) save_state() srv = await asyncio.start_server(handle, '127.0.0.1', port, limit=65536) log('terminal do LK Monitor escutando em 127.0.0.1:%d (sessões ficam %d min sem uso)' % (port, TTL // 60)) loop = asyncio.get_running_loop() stop = loop.create_future() for sig in (signal.SIGTERM, signal.SIGINT): loop.add_signal_handler(sig, lambda: stop.done() or stop.set_result(True)) asyncio.ensure_future(janitor()) async with srv: await stop for s in list(SESSIONS.values()): s.kill() SESSIONS.clear() save_state() if __name__ == '__main__': try: asyncio.run(main()) except KeyboardInterrupt: pass LKM_EOF_TERMPY find "$DIR" -maxdepth 1 -type f -exec chown root:"$LKUSER" {} + -exec chmod 644 {} + chown root:"$LKUSER" "$DIR/config.php"; chmod 640 "$DIR/config.php" chown -R root:root "$LIBDIR"; chmod 755 "$LIBDIR"; chmod 644 "$LIBDIR"/*.php "$LIBDIR"/*.py # ---------------------------------------------------------------- xterm.js (tela do terminal completo) # Baixado uma vez para o servidor; se não der (sem internet), o navegador usa a CDN. XTV=5.5.0; XFV=0.10.0; XDIR=$DIR/vendor/xterm mkdir -p "$XDIR" xt_get() { # xt_get local f=$XDIR/$1 tmp; tmp=$(mktemp) [[ -s $f ]] && grep -q "$4" "$f" && grep -q "$3" "$XDIR/.versao" 2>/dev/null && { rm -f "$tmp"; return 0; } for base in https://cdn.jsdelivr.net/npm https://unpkg.com; do if curl -fsSL --max-time 60 -o "$tmp" "$base/$3/$2" 2>/dev/null && [[ $(wc -c < "$tmp") -gt 1000 ]] && grep -q "$4" "$tmp"; then install -m 644 -o root -g "$LKUSER" "$tmp" "$f"; rm -f "$tmp"; return 0 fi done rm -f "$tmp"; return 1 } if command -v curl >/dev/null \ && xt_get xterm.js lib/xterm.js "@xterm/xterm@$XTV" Terminal \ && xt_get xterm.css css/xterm.css "@xterm/xterm@$XTV" xterm-viewport \ && xt_get addon-fit.js lib/addon-fit.js "@xterm/addon-fit@$XFV" FitAddon; then echo "@xterm/xterm@$XTV @xterm/addon-fit@$XFV" > "$XDIR/.versao" ok "xterm.js $XTV guardado no servidor (o terminal funciona mesmo sem internet no navegador)" else warn "Não foi possível baixar o xterm.js agora: o navegador vai buscar na internet (CDN) ao abrir o Terminal." fi chown root:"$LKUSER" "$DIR/vendor" "$XDIR"; chmod 755 "$DIR/vendor" "$XDIR" rm -f "$DIR/lib.php" "$DIR/collect.php" "$DIR/sites.php" "$DIR/db.php" "$DIR/job.php" "$DIR/jobs.inc.php" "$DIR/alerts.inc.php" # programas usados pelos backups (rsync na restauração, sqlite3 para copiar SQLite com segurança) for b in rsync sqlite3; do command -v "$b" >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$b" >/dev/null 2>&1 || warn "Não foi possível instalar $b" done # extensões do PHP: zip (compactar/extrair), sqlite3 (histórico e SQLite), mysql (aba Bancos) e curl (alertas) for ext in zip sqlite3 mysql curl; do mod=$ext; [[ $ext == sqlite3 ]] && mod=pdo_sqlite; [[ $ext == mysql ]] && mod=pdo_mysql if ! "php$PHPV" -m 2>/dev/null | grep -qi "^$mod\$"; then info "Instalando php$PHPV-$ext..." DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "php$PHPV-$ext" >/dev/null 2>&1 \ || warn "Não foi possível instalar php$PHPV-$ext ($(case $ext in zip) echo "compactar/extrair .zip";; sqlite3) echo "histórico de métricas e SQLite";; mysql) echo "aba Bancos (MySQL)";; *) echo "envio de alertas em paralelo";; esac) ficará indisponível)" fi done # ---------------------------------------------------------------- PHP-FPM próprio (usuário lkmonitor) info "Configurando PHP-FPM $PHPV (pool lkmonitor)..." for f in /etc/php/*/fpm/pool.d/lkmonitor.conf; do [[ -f $f && $f != /etc/php/$PHPV/* ]] && rm -f "$f"; done cat > "/etc/php/$PHPV/fpm/pool.d/lkmonitor.conf" </dev/null 2>&1; then "php-fpm$PHPV" -t rm -f "/etc/php/$PHPV/fpm/pool.d/lkmonitor.conf" die "Configuração do PHP-FPM com erro." fi systemctl restart "php$PHPV-fpm" || die "Falha ao reiniciar php$PHPV-fpm" for i in 1 2 3 4 5; do [[ -S /run/php/lkmonitor.sock ]] && break; sleep 1; done # ---------------------------------------------------------------- permissão do terminal if [[ -n $SHELL_USER ]]; then tmp=$(mktemp) echo "# LK Monitor - terminal web roda comandos como $SHELL_USER" > "$tmp" echo "$LKUSER ALL=($SHELL_USER) NOPASSWD: ALL" >> "$tmp" if visudo -cf "$tmp" >/dev/null 2>&1; then install -m 440 -o root -g root "$tmp" "$SUDOERS" ok "Terminal ativo (comandos como $SHELL_USER)" else warn "Não foi possível validar a regra do sudo; terminal ficará sem permissão." fi rm -f "$tmp" else rm -f "$SUDOERS" info "Terminal desativado" fi # ---------------------------------------------------------------- terminal completo (serviço lkmonitor-term) # Shell de verdade no navegador: lib/term.py (usuário lkmonitor) abre "sudo -u -i" num PTY. # Sem isolamento do systemd (ProtectSystem etc.): o shell aberto aqui herdaria as restrições. TERM_ON=0 if [[ -n $SHELL_USER && $TERM_PORT != 0 ]]; then if ! command -v python3 >/dev/null; then info "Instalando python3 (usado pelo terminal completo)..." DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-minimal >/dev/null 2>&1 || warn "Não foi possível instalar o python3" fi if command -v python3 >/dev/null && python3 -c 'import sys; sys.exit(sys.version_info < (3, 8))' 2>/dev/null; then mkdir -p "$DATA/term"; chown "$LKUSER:$LKUSER" "$DATA/term"; chmod 700 "$DATA/term" cat > /etc/systemd/system/lkmonitor-term.service </dev/null 2>&1 if grep -q 'lkmonitor-term' /proc/$$/cgroup 2>/dev/null; then # rodando de dentro do próprio terminal do painel: reiniciar agora mataria esta instalação systemd-run --quiet --on-active=10 --unit="lkm-term-restart-$$" /bin/systemctl restart lkmonitor-term.service >/dev/null 2>&1 \ || warn "Reinicie o terminal depois com: sudo systemctl restart lkmonitor-term" warn "Você está no terminal do painel: ele reinicia em 10 s para pegar a versão nova (a tela reconecta sozinha, mas o que estiver rodando nele para)." TERM_ON=1 elif systemctl restart lkmonitor-term.service; then sleep 1 if systemctl is-active --quiet lkmonitor-term.service; then TERM_ON=1; ok "Terminal completo ativo (serviço lkmonitor-term, porta interna $TERM_PORT)" else warn "O terminal completo não iniciou (veja: journalctl -u lkmonitor-term -n 30). O modo simples continua."; fi else warn "O terminal completo não iniciou (veja: journalctl -u lkmonitor-term -n 30). O modo simples continua." fi else warn "Python 3.8 ou mais novo não encontrado: o terminal completo fica desativado (o modo simples continua)." fi fi if [[ $TERM_ON == 0 ]]; then systemctl disable --now lkmonitor-term.service >/dev/null 2>&1 [[ -z $SHELL_USER ]] && rm -f /etc/systemd/system/lkmonitor-term.service fi # ---------------------------------------------------------------- histórico de métricas e alertas (systemd timer) info "Configurando o coletor de métricas e alertas (a cada 60 s)..." # grupos para ler o log do SSH (falhas de login) sem dar isso ao painel LOGGRP=""; for g in adm systemd-journal; do getent group "$g" >/dev/null && LOGGRP+="$g "; done cat > /etc/systemd/system/lkmonitor-collect.service < /etc/systemd/system/lkmonitor-collect.timer <<'EOF' [Unit] Description=LK Monitor - coleta de métricas a cada minuto [Timer] OnCalendar=*-*-* *:*:00 AccuracySec=1s [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable --now lkmonitor-collect.timer >/dev/null 2>&1 || warn "Não foi possível ativar o lkmonitor-collect.timer" systemctl start lkmonitor-collect.service >/dev/null 2>&1 \ && ok "Coletor ativo: histórico de 7 dias em $DATA/metrics.sqlite" \ || warn "O coletor falhou na primeira execução (veja: journalctl -u lkmonitor-collect)" # ---------------------------------------------------------------- backup automático (systemd timer, horário configurável no painel) info "Configurando o backup automático..." cat > /etc/systemd/system/lkmonitor-backup.service </dev/null); do [[ $ip == *:* || $ip == 172.17.* || $ip == 192.168.122.* ]] || echo "$ip"; done; } make_ca() { local nc="permitted;IP:10.0.0.0/255.0.0.0,permitted;IP:172.16.0.0/255.240.0.0,permitted;IP:192.168.0.0/255.255.0.0" nc+=",permitted;IP:100.64.0.0/255.192.0.0,permitted;IP:127.0.0.0/255.0.0.0,permitted;DNS:localhost,permitted;DNS:local,permitted;DNS:lan" nc+=",permitted;DNS:$(hostname -s | tr 'A-Z' 'a-z')" local ip; for ip in $(lan_ips); do nc+=",permitted;IP:$ip/255.255.255.255"; done openssl genrsa -out "$CADIR/ca.key" 2048 >/dev/null 2>&1 || return 1 chmod 600 "$CADIR/ca.key" openssl req -x509 -new -key "$CADIR/ca.key" -sha256 -days 3650 -out "$CADIR/ca.crt" \ -subj "/O=LK Monitor/CN=LK Monitor CA ($(hostname -s))" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ -addext "keyUsage=critical,keyCertSign,cRLSign" \ -addext "subjectKeyIdentifier=hash" \ -addext "nameConstraints=critical,$nc" >/dev/null 2>&1 || return 1 } cert_ok() { # o certificado atual serve? (assinado pela CA, válido por mais 30 dias, com todos os IPs atuais) local f=$CADIR/server.crt san ip [[ -s $f && -s $CADIR/server.key ]] || return 1 openssl verify -CAfile "$CADIR/ca.crt" "$f" >/dev/null 2>&1 || return 1 openssl x509 -in "$f" -noout -checkend 2592000 >/dev/null 2>&1 || return 1 san=$(openssl x509 -in "$f" -noout -ext subjectAltName 2>/dev/null) for ip in $(lan_ips); do grep -q "IP Address:$ip\(,\|$\)" <<< "$san" || return 1; done return 0 } make_cert() { local h; h=$(hostname -s | tr 'A-Z' 'a-z') local san="DNS:$h,DNS:localhost,IP:127.0.0.1" ip ext for ip in $(lan_ips); do san+=",IP:$ip"; done ext=$(mktemp) printf 'basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=%s\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid\n' "$san" > "$ext" openssl req -new -newkey rsa:2048 -nodes -keyout "$CADIR/server.key.new" -subj "/O=LK Monitor/CN=$h" -out "$CADIR/server.csr" >/dev/null 2>&1 \ && openssl x509 -req -in "$CADIR/server.csr" -CA "$CADIR/ca.crt" -CAkey "$CADIR/ca.key" -set_serial "0x$(openssl rand -hex 16)" \ -days 730 -sha256 -extfile "$ext" -out "$CADIR/server.crt.new" >/dev/null 2>&1 local rc=$? rm -f "$ext" "$CADIR/server.csr" ((rc == 0)) || { rm -f "$CADIR/server.key.new" "$CADIR/server.crt.new"; return 1; } chmod 600 "$CADIR/server.key.new" mv -f "$CADIR/server.key.new" "$CADIR/server.key"; mv -f "$CADIR/server.crt.new" "$CADIR/server.crt" } HTTPS_ON=0 if [[ $HTTPS_PORT != 0 ]]; then info "Configurando HTTPS na porta $HTTPS_PORT (certificado próprio do servidor)..." if ! command -v openssl >/dev/null; then DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssl >/dev/null 2>&1 fi mkdir -p "$CADIR"; chown root:root "$CADIR"; chmod 755 "$CADIR" if [[ ! -s $CADIR/ca.key || ! -s $CADIR/ca.crt ]]; then rm -f "$CADIR"/server.* make_ca && ok "Autoridade certificadora local criada em $CADIR" || warn "Falha ao criar a CA local" fi if [[ -s $CADIR/ca.crt ]]; then chmod 644 "$CADIR/ca.crt"; chmod 600 "$CADIR/ca.key" if cert_ok; then ok "Certificado HTTPS em dia" else make_cert && ok "Certificado HTTPS gerado (válido por 2 anos: $(hostname -s), $(lan_ips | tr '\n' ' '))" || warn "Falha ao gerar o certificado HTTPS"; fi fi if [[ -s $CADIR/server.crt && -s $CADIR/server.key ]] && a2enmod -q ssl socache_shmcb >/dev/null 2>&1; then HTTPS_ON=1 else warn "HTTPS não ativado; o painel segue só em HTTP."; HTTPS_PORT=0; write_config; fi fi # ---------------------------------------------------------------- Apache vhost_body() { # vhost_body cat < Options -Indexes AllowOverride None $REQ Require all denied SetHandler "proxy:unix:/run/php/lkmonitor.sock|fcgi://lkmonitor" AddType application/manifest+json .webmanifest $([[ $TERM_PORT != 0 ]] && printf ' \n %s\n ProxyPreserveHost On\n ProxyPass ws://127.0.0.1:%s/term-ws\n \n' "$REQ" "$TERM_PORT") ProxyTimeout 600 LimitRequestBody 0 ErrorLog \${APACHE_LOG_DIR}/lkmonitor_error.log CustomLog \${APACHE_LOG_DIR}/lkmonitor_access.log combined EOF } info "Configurando Apache na porta $PORT$([[ $HTTPS_ON == 1 ]] && echo " e HTTPS na $HTTPS_PORT")..." [[ -f $SITE ]] && cp -f "$SITE" "$SITE.lkm-bak" { echo "# LK Monitor - gerado em $(date '+%F %T')" echo "Listen $PORT" echo "" vhost_body lkmonitor echo "" if [[ $HTTPS_ON == 1 ]]; then echo "Listen $HTTPS_PORT https" echo "" vhost_body "$(hostname -s | tr 'A-Z' 'a-z')" echo " SSLEngine on" echo " SSLCertificateFile $CADIR/server.crt" echo " SSLCertificateKeyFile $CADIR/server.key" echo "" fi } > "$SITE" a2enmod proxy proxy_fcgi proxy_wstunnel setenvif mime >/dev/null 2>&1 a2ensite lkmonitor >/dev/null if ! apache2ctl configtest >/dev/null 2>&1; then apache2ctl configtest if [[ -f $SITE.lkm-bak ]]; then mv -f "$SITE.lkm-bak" "$SITE" apache2ctl configtest >/dev/null 2>&1 || a2dissite lkmonitor >/dev/null 2>&1 die "Configuração do Apache com erro. A configuração anterior do painel foi mantida." fi a2dissite lkmonitor >/dev/null 2>&1 die "Configuração do Apache com erro. Painel não ativado." fi rm -f "$SITE.lkm-bak" systemctl reload apache2 || systemctl restart apache2 if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then ufw allow "$PORT/tcp" >/dev/null && info "Porta $PORT liberada no UFW" [[ $HTTPS_ON == 1 ]] && ufw allow "$HTTPS_PORT/tcp" >/dev/null && info "Porta $HTTPS_PORT liberada no UFW" fi echo echo "${G}=============================================${N}" echo "${G} LK Monitor $([[ $MODE == update ]] && echo atualizado || echo instalado)!${N}" echo "${G}=============================================${N}" echo " Abra no navegador:" echo " ${C}http://localhost:$PORT${N}" for ip in $(lan_ips); do echo " ${C}http://$ip:$PORT${N}"; done if [[ $HTTPS_ON == 1 ]]; then echo " Endereço seguro (app no celular - instale o certificado pelo botão \"Instalar no celular\" do painel):" for ip in $(lan_ips); do echo " ${C}https://$ip:$HTTPS_PORT${N}"; done fi echo [[ -n $SHELL_USER ]] && echo " Terminal: comandos rodam como ${Y}$SHELL_USER${N} (histórico em $DATA/cmd.log)" [[ $TERM_ON == 1 ]] && echo " Terminal completo: aba Terminal do painel (nano, htop, o menu do lk...). Log: journalctl -u lkmonitor-term" [[ $SHELL_USER != root ]] && echo " Sites: ${Y}somente leitura${N} (para gerenciar pelo painel, escolha root no acesso do terminal)" show_temp echo " Trocar senha: ${C}sudo lk senha${N} (ou pelo painel: aba Internet › Senha do painel)" echo " Remover: sudo bash $0 uninstall" echo LK_MONITOR_INSTALLER_EOF chmod 755 "$SHARE/install-lk-monitor.sh" # WhatsApp no servidor + bot (Guardião LK): instalado só quando pedir (sudo lk whatsapp, opção 37 ou App Store) cat > "$SHARE/install-whatsapp.sh" <<'LK_WHATSAPP_INSTALLER_EOF' #!/usr/bin/env bash # ===================================================================== # WhatsApp no servidor: GOWA (go-whatsapp-web-multidevice) + página PHP # https://github.com/aldinokemal/go-whatsapp-web-multidevice # # Uso: # sudo bash install-whatsapp.sh instala / atualiza # sudo bash install-whatsapp.sh info mostra endereços e dados para os alertas do LK # sudo bash install-whatsapp.sh senha troca a senha da página # sudo bash install-whatsapp.sh lk liga os alertas do LK Monitor ao WhatsApp deste servidor # sudo bash install-whatsapp.sh guardiao configura o Guardião (aviso de temperatura e comandos pelo WhatsApp) # sudo bash install-whatsapp.sh uninstall remove tudo # Sem acesso ao GitHub: baixe o whatsapp__linux_amd64.zip no PC e rode # sudo GOWA_ZIP=/root/whatsapp__linux_amd64.zip bash install-whatsapp.sh # ===================================================================== [[ $EUID -ne 0 ]] && exec sudo bash "$(readlink -f "$0")" "$@" G=$'\e[32m'; R=$'\e[31m'; C=$'\e[36m'; Y=$'\e[33m'; W=$'\e[1m'; N=$'\e[0m' ok() { echo "${G}[OK]${N} $*"; } info() { echo "${C}[..]${N} $*"; } warn() { echo "${Y}[!]${N} $*"; } die() { echo "${R}[ERRO]${N} $*"; exit 1; } REPO="aldinokemal/go-whatsapp-web-multidevice" GDIR=/opt/gowa ETC=/etc/gowa ENVF=$ETC/gowa.env SVC=/etc/systemd/system/gowa.service WDIR=/var/www/whatsapp SITE=/etc/apache2/sites-available/whatsapp-panel.conf DEVICE=lk GCONF=/etc/lk-guard.conf GSVC=/etc/systemd/system/lk-guard.service envget() { [[ -f $ENVF ]] && grep -m1 "^$1=" "$ENVF" | cut -d= -f2-; } gconf_get() { [[ -f $GCONF ]] && grep -m1 "^$1=" "$GCONF" | cut -d= -f2-; } # grava/troca uma linha KEY=valor no gowa.env; devolve 0 se mudou envset() { local k=$1 v=$2 [[ $(envget "$k") == "$v" ]] && grep -q "^$k=" "$ENVF" && return 1 if grep -q "^$k=" "$ENVF"; then sed -i "s|^$k=.*|$k=$v|" "$ENVF"; else echo "$k=$v" >> "$ENVF"; fi return 0 } port_busy() { ss -ltnH "sport = :$1" 2>/dev/null | grep -q .; } lan_ips() { hostname -I 2>/dev/null | tr ' ' '\n' | grep -vE '^$|:|^172\.17\.|^192\.168\.122\.' ; } show_info() { local port user pass b64 pport port=$(envget APP_PORT); user=$(envget GOWA_USER); pass=$(envget GOWA_PASS) [[ -n $port && -n $user ]] || die "O WhatsApp (GOWA) não está instalado" b64=$(printf '%s:%s' "$user" "$pass" | base64 -w0) pport=""; [[ -f $SITE ]] && pport=$(grep -m1 -oE '^Listen [0-9]+' "$SITE" | awk '{print $2}') echo echo "${W}Serviço:${N} gowa ($(systemctl is-active gowa 2>/dev/null)) em 127.0.0.1:$port" if [[ -n $pport ]]; then echo "${W}Página (conecte o número por QR code ou código e envie mensagens):${N}" for ip in $(lan_ips); do echo " ${C}http://$ip:$pport${N}"; done fi echo echo "${W}Para os alertas do LK Monitor${N} (Config › Alertas › WhatsApp):" echo " Método: POST" echo " URL: http://127.0.0.1:$port/send/message" echo " Cabeçalhos:" echo " Authorization: Basic $b64" echo " X-Device-Id: $DEVICE" echo " Content-Type: application/json" echo " Corpo: {\"phone\": \"{phone}@s.whatsapp.net\", \"message\": \"{message}\"}" echo " Número: o seu, com código do país (ex.: 50761234567)" echo echo "${W}Mídia pela API${N} (mesmos Authorization e X-Device-Id, corpo multipart/form-data):" echo " /send/image phone, image_url ou arquivo em image, caption" echo " /send/video phone, video_url ou arquivo em video, caption" echo " /send/audio phone, audio_url ou arquivo em audio, ptt=true (mensagem de voz)" echo " /send/file phone, file_url ou arquivo em file, caption" echo " Exemplo: curl -H 'Authorization: Basic $b64' -H 'X-Device-Id: $DEVICE' -F phone=50761234567@s.whatsapp.net \\" echo " -F image_url=https://site.com/foto.jpg -F caption=Oi http://127.0.0.1:$port/send/image" echo if [[ -f /opt/lk-guard/guard.php ]]; then local gn; gn=$(gconf_get NUMBERS) echo "${W}Guardião${N} ($(systemctl is-active lk-guard 2>/dev/null)): avisa acima de $(gconf_get WARN_TEMP) °C e desliga acima de $(gconf_get CRIT_TEMP) °C" echo " Números: ${gn:-nenhum (configure com: sudo lk whatsapp guardiao)}" echo " Mande no WhatsApp para o número do servidor: STATUS, REINICIAR, DESLIGAR ou AJUDA" echo fi } # Pergunta os números do Guardião e grava /etc/lk-guard.conf (mantém os limites já configurados) configure_guard() { local cur nums cur=$(gconf_get NUMBERS) echo echo "${W}Guardião:${N} avisa no seu WhatsApp se a CPU esquentar demais e deixa você mandar" echo "STATUS, REINICIAR ou DESLIGAR para o número do servidor." echo "Use o seu número pessoal (não o número que está conectado no servidor)." if [[ ${WA_AUTO:-0} == 1 ]]; then nums=${WA_NUMBERS:-} else read -rp "Seu número do WhatsApp (ex.: 50761234567; vários separados por vírgula)${cur:+ [$cur]}: " nums; fi nums=${nums:-$cur} nums=$(tr -cd '0-9,' <<< "$nums" | sed -E 's/,+/,/g; s/^,//; s/,$//') local wt wm rp ct cm ao bot bot=$(gconf_get BOT) wt=$(gconf_get WARN_TEMP); wm=$(gconf_get WARN_MIN); rp=$(gconf_get REPEAT_MIN) ct=$(gconf_get CRIT_TEMP); cm=$(gconf_get CRIT_MIN); ao=$(gconf_get AUTO_OFF) cat > "$GCONF" </dev/null) [[ -z $h && -f /var/www/lkmonitor/config.php ]] && h=$(grep -oP "define\('LKM_PASS_HASH', '\K[^']*" /var/www/lkmonitor/config.php | head -n1) echo "$h" } ask_pass() { local p1 p2 if [[ ${WA_AUTO:-0} == 1 ]]; then PAGE_HASH=$(lk_panel_hash) if [[ $PAGE_HASH == \$* ]]; then info "Senha da página do WhatsApp: a mesma do painel"; return 0; fi p1=$(tr -dc 'A-HJ-NP-Za-km-z2-9' "$WDIR/config.php" chown root:www-data "$WDIR/config.php"; chmod 640 "$WDIR/config.php" } # Entrega ao LK Monitor os dados do GOWA local (alertas pelo WhatsApp sem configurar gateway) lkmon_link() { local f=/var/lib/lkmonitor/gowa.json id lkmonitor >/dev/null 2>&1 && [[ -d /var/lib/lkmonitor ]] || return 0 [[ -n $(envget APP_PORT) ]] || return 0 printf '{"url":"http://127.0.0.1:%s","user":"%s","pass":"%s","device":"%s","page_port":"%s"}\n' \ "$(envget APP_PORT)" "$(envget GOWA_USER)" "$(envget GOWA_PASS)" "$DEVICE" "${PPORT:-}" > "$f" chown lkmonitor:lkmonitor "$f"; chmod 600 "$f" ok "LK Monitor ligado ao WhatsApp deste servidor" } case "${1:-}" in info) show_info; exit 0;; lk) PPORT=""; [[ -f $SITE ]] && PPORT=$(grep -m1 -oE '^Listen [0-9]+' "$SITE" | awk '{print $2}') id lkmonitor >/dev/null 2>&1 || die "O LK Monitor não está instalado" lkmon_link; exit 0;; guardiao|guard) [[ -f /opt/lk-guard/guard.php ]] || die "O Guardião não está instalado. Rode primeiro: sudo lk whatsapp" configure_guard systemctl restart lk-guard [[ -n $(gconf_get NUMBERS) ]] && { sleep 1; php /opt/lk-guard/guard.php teste; } exit 0;; senha|password) [[ -f $WDIR/config.php ]] || die "A página do WhatsApp não está instalada" ask_pass; write_page_config; ok "Senha da página alterada"; exit 0;; uninstall) if [[ ${WA_AUTO:-0} != 1 ]]; then read -rp "Remover o WhatsApp do servidor? O número conectado será desconectado. [s/N]: " a [[ $a =~ ^[sSyY]$ ]] || exit 0 fi systemctl disable --now gowa lk-guard >/dev/null 2>&1 rm -f "$SVC" "$GSVC"; systemctl daemon-reload rm -rf /opt/lk-guard /var/lib/lk-guard "$GCONF" a2dissite whatsapp-panel >/dev/null 2>&1; rm -f "$SITE"; systemctl reload apache2 2>/dev/null rm -rf "$WDIR" "$GDIR" "$ETC" rm -f /var/lib/lkmonitor/gowa.json id gowa >/dev/null 2>&1 && userdel gowa 2>/dev/null ok "WhatsApp removido"; exit 0;; ""|install|update) ;; *) die "Opção desconhecida: $1 (use: info, senha, lk, guardiao, uninstall)";; esac command -v apt-get >/dev/null || die "Suporta apenas Ubuntu/Debian." case "$(uname -m)" in x86_64|amd64) ARCH=amd64;; aarch64|arm64) ARCH=arm64;; armv7l|armv7*) ARCH=armv7;; i386|i686) ARCH=386;; *) die "Arquitetura não suportada: $(uname -m)";; esac info "Instalando dependências (curl, unzip)..." DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl unzip ca-certificates >/dev/null 2>&1 || true command -v curl >/dev/null && command -v unzip >/dev/null || die "Não foi possível instalar curl/unzip" # ffmpeg: necessário para enviar vídeo e mensagem de voz | webp: figurinhas animadas if ! command -v ffmpeg >/dev/null || ! command -v webpmux >/dev/null; then info "Instalando ffmpeg e webp (envio de vídeo, áudio de voz e figurinhas)... pode levar alguns minutos" DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ffmpeg webp >/dev/null 2>&1 \ || { apt-get update -qq >/dev/null 2>&1; DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ffmpeg webp >/dev/null 2>&1; } fi command -v ffmpeg >/dev/null && ok "ffmpeg instalado" || warn "ffmpeg não instalado: envio de vídeo e mensagem de voz não vai funcionar (tente: apt install ffmpeg)" # ---------------------------------------------------------------- download do GOWA # GOWA_VERSION=x.y.z fixa a versão | GOWA_ZIP=/caminho/whatsapp_x.y.z_linux_amd64.zip instala sem internet info "Buscando a versão mais recente do GOWA..." if [[ -n ${GOWA_ZIP:-} ]]; then V=$(basename "$GOWA_ZIP" | sed -nE 's/^whatsapp_([^_]+)_linux_.*/\1/p'); V=${V:-local}; URL="file://$GOWA_ZIP" elif [[ -n ${GOWA_VERSION:-} ]]; then V=${GOWA_VERSION#v} URL="https://github.com/$REPO/releases/download/v$V/whatsapp_${V}_linux_${ARCH}.zip" else JSON=$(curl -fsSL --max-time 30 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPO/releases/latest") \ || die "Não foi possível consultar o GitHub. Verifique a internet do servidor (ou defina GOWA_VERSION=x.y.z)." V=$(grep -m1 -oE '"tag_name": *"[^"]+"' <<< "$JSON" | sed -E 's/.*"v?([^"]+)"$/\1/') URL=$(grep -oE '"browser_download_url": *"[^"]+_linux_'"$ARCH"'\.zip"' <<< "$JSON" | head -n1 | sed -E 's/.*"(https[^"]+)"$/\1/') fi [[ -n $URL ]] || die "Não encontrei o arquivo do GOWA para linux/$ARCH na última versão." CUR=""; [[ -f $GDIR/VERSION ]] && CUR=$(<"$GDIR/VERSION") TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT if [[ -n ${GOWA_ZIP:-} ]]; then [[ -f $GOWA_ZIP ]] || die "Arquivo não encontrado: $GOWA_ZIP" cp "$GOWA_ZIP" "$TMP/gowa.zip"; CUR="" fi if [[ $CUR == "$V" && -x $GDIR/gowa && -z ${GOWA_ZIP:-} ]]; then ok "GOWA já está na versão $V" else if [[ ! -f $TMP/gowa.zip ]]; then info "Baixando GOWA $V (linux/$ARCH)..." curl -fSL --max-time 300 -o "$TMP/gowa.zip" "$URL" || die "Falha ao baixar $URL" fi unzip -q -o "$TMP/gowa.zip" -d "$TMP/x" || die "Arquivo baixado inválido" BIN=$(find "$TMP/x" -type f \( -name "linux-*" -o -name "whatsapp" \) ! -name "*.txt" ! -name "*.md" | head -n1) [[ -n $BIN ]] || BIN=$(find "$TMP/x" -type f -size +5M | head -n1) [[ -n $BIN ]] || die "Não encontrei o executável dentro do zip" id gowa >/dev/null 2>&1 || useradd --system --home-dir "$GDIR" --no-create-home --shell /usr/sbin/nologin gowa || die "Falha ao criar o usuário gowa" mkdir -p "$GDIR" systemctl stop gowa >/dev/null 2>&1 install -m 755 "$BIN" "$GDIR/gowa" echo "$V" > "$GDIR/VERSION" ok "GOWA $V instalado em $GDIR" fi id gowa >/dev/null 2>&1 || useradd --system --home-dir "$GDIR" --no-create-home --shell /usr/sbin/nologin gowa mkdir -p "$GDIR/storages" "$GDIR/statics" chown -R gowa:gowa "$GDIR/storages" "$GDIR/statics"; chmod 750 "$GDIR/storages" # ---------------------------------------------------------------- configuração e serviço mkdir -p "$ETC" if [[ ! -f $ENVF ]]; then P=3000 while port_busy "$P"; do P=$((P + 1)); done PASS=$(tr -dc 'A-Za-z0-9' "$ENVF" < "$SVC" <<'EOF' [Unit] Description=GOWA - WhatsApp API (go-whatsapp-web-multidevice) After=network-online.target Wants=network-online.target [Service] User=gowa Group=gowa WorkingDirectory=/opt/gowa EnvironmentFile=/etc/gowa/gowa.env ExecStart=/opt/gowa/gowa rest --host=${APP_HOST} --port=${APP_PORT} --os=${APP_OS} --basic-auth=${APP_BASIC_AUTH} Restart=always RestartSec=5 NoNewPrivileges=true PrivateTmp=true [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable gowa >/dev/null 2>&1 systemctl restart gowa info "Aguardando o serviço subir na porta $PORT..." UPOK=0 for i in $(seq 1 30); do code=$(curl -s -o /dev/null -w '%{http_code}' -u "$(envget GOWA_USER):$(envget GOWA_PASS)" "http://127.0.0.1:$PORT/devices" 2>/dev/null) [[ $code == 200 ]] && { UPOK=1; break; } sleep 1 done if [[ $UPOK == 1 ]]; then ok "Serviço gowa rodando" curl -s -o /dev/null -u "$(envget GOWA_USER):$(envget GOWA_PASS)" -H 'Content-Type: application/json' \ -d "{\"device_id\":\"$DEVICE\"}" "http://127.0.0.1:$PORT/devices" || true else warn "O serviço não respondeu. Veja o log com: journalctl -u gowa -n 50 --no-pager" fi # ---------------------------------------------------------------- página PHP if [[ ! -d /etc/apache2 ]] || ! command -v php >/dev/null; then warn "Apache/PHP não encontrados: a página não foi instalada (instale o LAMP com: lk → 1, e rode este instalador de novo)." show_info; exit 0 fi PHPV="" for v in $(ls /etc/php 2>/dev/null | grep -E '^[0-9]+\.[0-9]+$' | sort -V); do [[ -S /run/php/php$v-fpm.sock || -d /etc/php/$v/fpm ]] && PHPV=$v; done [[ -n $PHPV ]] || die "PHP-FPM não encontrado (instale o LAMP com: lk → 1)." php -m 2>/dev/null | grep -qi '^curl$' || { info "Instalando php$PHPV-curl..."; DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "php$PHPV-curl" >/dev/null 2>&1; } php -m 2>/dev/null | grep -qi '^mbstring$' || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "php$PHPV-mbstring" >/dev/null 2>&1 OLDPP=""; [[ -f $SITE ]] && OLDPP=$(grep -m1 -oE '^Listen [0-9]+' "$SITE" | awk '{print $2}') if [[ -n $OLDPP ]]; then PPORT=$OLDPP else DEF=8089; while port_busy "$DEF"; do DEF=$((DEF + 1)); done if [[ ${WA_AUTO:-0} == 1 ]]; then PPORT=$DEF; else read -rp "Porta da página do WhatsApp [$DEF]: " PPORT; fi PPORT=${PPORT:-$DEF} [[ $PPORT =~ ^[0-9]+$ ]] && ((PPORT >= 1024 && PPORT <= 65535)) || die "Porta inválida" port_busy "$PPORT" && die "A porta $PPORT já está em uso" grep -qsE "^\s*Listen\s+(\S+:)?$PPORT\s*$" /etc/apache2/ports.conf && die "A porta $PPORT já está no ports.conf do Apache" fi mkdir -p "$WDIR" if [[ -f $WDIR/config.php ]] && grep -q "WA_PASS_HASH" "$WDIR/config.php"; then PAGE_HASH=$(grep -oP "define\('WA_PASS_HASH', '\K[^']*" "$WDIR/config.php") K=""; [[ ${WA_AUTO:-0} == 1 ]] || read -rp "Manter a senha atual da página? [S/n]: " K [[ $K =~ ^[nN]$ || -z $PAGE_HASH ]] && ask_pass else ask_pass fi write_page_config info "Instalando a página em $WDIR..." cat > "$WDIR/index.php" <<'LK_WA_PAGE_EOF' ['path' => '/send/image', 'field' => 'image', 'max' => 20, 'label' => 'Imagem', 'fmt' => 'JPG ou PNG', 'mimes' => ['image/jpeg', 'image/png'], 'ext' => [], 'cap' => true, 'opts' => ['view_once', 'compress']], 'video' => ['path' => '/send/video', 'field' => 'video', 'max' => 99, 'label' => 'Vídeo', 'fmt' => 'MP4, MKV ou AVI', 'mimes' => ['video/mp4', 'video/x-matroska', 'video/avi', 'video/x-msvideo'], 'ext' => ['mp4' => 'video/mp4', 'mkv' => 'video/x-matroska', 'avi' => 'video/x-msvideo'], 'cap' => true, 'opts' => ['view_once', 'compress', 'gif_playback']], 'audio' => ['path' => '/send/audio', 'field' => 'audio', 'max' => 50, 'label' => 'Áudio', 'fmt' => 'MP3, M4A, OGG, WAV, AAC, FLAC ou AMR', 'mimes' => ['audio/aac', 'audio/amr', 'audio/flac', 'audio/m4a', 'audio/mp3', 'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/x-ms-wma'], 'ext' => ['mp3' => 'audio/mpeg', 'm4a' => 'audio/m4a', 'aac' => 'audio/aac', 'ogg' => 'audio/ogg', 'oga' => 'audio/ogg', 'opus' => 'audio/ogg', 'wav' => 'audio/wav', 'flac' => 'audio/flac', 'amr' => 'audio/amr', 'wma' => 'audio/x-ms-wma'], 'cap' => false, 'opts' => ['ptt']], 'file' => ['path' => '/send/file', 'field' => 'file', 'max' => 50, 'label' => 'Arquivo', 'fmt' => 'qualquer formato', 'mimes' => null, 'ext' => [], 'cap' => true, 'opts' => []], 'sticker' => ['path' => '/send/sticker', 'field' => 'sticker', 'max' => 20, 'label' => 'Figurinha', 'fmt' => 'JPG, PNG, WebP ou GIF', 'mimes' => ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], 'ext' => [], 'cap' => false, 'opts' => []], ]; const KIND_LABEL = ['text' => 'Texto', 'image' => 'Imagem', 'video' => 'Vídeo', 'audio' => 'Áudio', 'file' => 'Arquivo', 'sticker' => 'Figurinha', 'location' => 'Local', 'contact' => 'Contato', 'link' => 'Link', 'poll' => 'Enquete']; session_name('lkwhats'); session_set_cookie_params(['lifetime' => 0, 'httponly' => true, 'samesite' => 'Strict']); session_start(); header('X-Frame-Options: DENY'); header('X-Content-Type-Options: nosniff'); header('Referrer-Policy: no-referrer'); if (isset($_GET['sair'])) { $_SESSION = []; session_destroy(); header('Location: ./'); exit; } if (empty($_SESSION['ok'])) { $err = ''; if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_POST['senha'])) { if (password_verify((string)$_POST['senha'], WA_PASS_HASH)) { session_regenerate_id(true); $_SESSION['ok'] = 1; header('Location: ./'); exit; } sleep(2); $err = 'Senha incorreta'; } if (isset($_GET['a'])) out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 401); page_login($err); exit; } if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16)); if (isset($_GET['a'])) { $tok = (string)($_SERVER['HTTP_X_TOKEN'] ?? ''); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !hash_equals($_SESSION['csrf'], $tok)) { out(['ok' => false, 'error' => 'Sessão expirada. Recarregue a página.'], 403); } if (str_starts_with(strtolower((string)($_SERVER['CONTENT_TYPE'] ?? '')), 'multipart/form-data')) { if (!$_POST && !$_FILES && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { out(['ok' => false, 'error' => 'Arquivo grande demais para esta página (máximo ' . ini_mb('post_max_size') . ' MB).']); } $in = $_POST; } else { $in = json_decode((string)file_get_contents('php://input'), true); } session_write_close_later(); action((string)$_GET['a'], is_array($in) ? $in : []); } page_app(); /* ------------------------------------------------------------------ utilidades */ function out(array $d, int $code = 200): never { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); echo json_encode($d, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit; } function fail(string $msg): never { out(['ok' => false, 'error' => $msg]); } /** Não segura a sessão durante envios longos (o histórico é gravado reabrindo a sessão). */ function session_write_close_later(): void { if (session_status() === PHP_SESSION_ACTIVE) session_write_close(); } function ini_mb(string $key): int { $v = trim((string)ini_get($key)); $n = (float)$v; $u = strtoupper(substr($v, -1)); $b = match ($u) { 'G' => $n * 1073741824, 'M' => $n * 1048576, 'K' => $n * 1024, default => $n }; return (int)floor($b / 1048576); } /** Chama a API do GOWA. Retorna [status HTTP, corpo decodificado (array) ou bruto (string)]. */ function gw(string $method, string $path, array $query = [], ?array $body = null, bool $raw = false, ?array $form = null, int $timeout = 40): array { $url = rtrim(WA_GOWA_URL, '/') . $path . ($query ? '?' . http_build_query($query) : ''); $ch = curl_init($url); $headers = ['Accept: application/json', 'X-Device-Id: ' . WA_DEVICE, 'Expect:']; if ($form !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $form); } elseif ($body !== null) { $headers[] = 'Content-Type: application/json'; curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_USERPWD => WA_GOWA_USER . ':' . WA_GOWA_PASS, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => $timeout, ]); $res = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $errno = curl_errno($ch); if ($errno === CURLE_OPERATION_TIMEDOUT) fail('O envio demorou demais. Tente um arquivo menor ou verifique a internet do servidor.'); if ($errno || $res === false) fail('O serviço do WhatsApp não está respondendo. No servidor, rode: systemctl restart gowa'); if ($raw) return [$code, (string)$res]; $d = json_decode((string)$res, true); return [$code, is_array($d) ? $d : ['message' => trim((string)$res)]]; } /** Traduz as mensagens de erro mais comuns do GOWA. */ function gw_error(array $d, string $fallback): string { $m = trim((string)($d['message'] ?? '')); if ($m === '') return $fallback; $map = [ '/not log|logged out|not connected|no active session/i' => 'O número não está conectado. Conecte de novo na coluna ao lado.', '/ffmpeg not installed/i' => 'Falta o ffmpeg no servidor. Rode de novo: sudo bash /root/install-whatsapp.sh', '/max (file|video|image) (upload|download|size)|too large|exceeds/i' => 'Arquivo grande demais para o WhatsApp.', '/(image|video|audio|sticker).* (is )?not allowed/i' => 'Formato não aceito para este tipo de envio.', '/must be a valid url|invalid url/i' => 'Link inválido.', '/(failed|error).*(download|fetch)|download.*(failed|error)|status code [45]\d\d/i' => 'Não foi possível baixar o arquivo do link. Confira se o link abre direto o arquivo.', ]; foreach ($map as $re => $pt) { if (preg_match($re, $m)) return $pt; } return $m; } function call(string $path, array $body): array { [$code, $d] = gw('POST', $path, [], $body); if ($code >= 400) fail(gw_error($d, 'Falha ao enviar')); return $d; } /** Garante que o device existe no GOWA (cria se faltar). Devolve os dados dele. */ function ensure_device(): array { [$code, $d] = gw('GET', '/devices'); if ($code >= 400) fail(gw_error($d, 'Falha ao listar os aparelhos do serviço')); foreach ((array)($d['results'] ?? []) as $dev) { if (is_array($dev) && ($dev['id'] ?? '') === WA_DEVICE) return $dev; } [$code, $d] = gw('POST', '/devices', [], ['device_id' => WA_DEVICE]); if ($code >= 400) fail(gw_error($d, 'Falha ao preparar o aparelho no serviço')); return (array)($d['results'] ?? []); } function jid_of(string $to): string { $to = trim($to); if ($to === '') fail('Digite o número de destino'); if (str_contains($to, '@')) { if (!preg_match('/^[0-9A-Za-z._:-]+@(s\.whatsapp\.net|g\.us|lid|newsletter)$/', $to)) fail('Destino inválido'); return $to; } $d = preg_replace('/\D+/', '', $to); if (strlen($d) < 8 || strlen($d) > 15) fail('Número inválido. Use o código do país + número, só dígitos (ex.: 50761234567).'); return $d . '@s.whatsapp.net'; } function valid_url(string $u): string { $u = str_replace(' ', '%20', trim($u)); if ($u === '') fail('Cole o link (URL)'); if (!preg_match('#^https?://#i', $u) || !filter_var($u, FILTER_VALIDATE_URL)) fail('Link inválido. Use um endereço que comece com http:// ou https://'); if (strlen($u) > 2000) fail('Link longo demais'); return $u; } function text_in(array $in, string $key, int $max, string $name, bool $required = false): string { $v = trim((string)($in[$key] ?? '')); if ($required && $v === '') fail('Preencha: ' . $name); if (mb_strlen($v) > $max) fail($name . ' muito longo (máximo ' . $max . ' caracteres)'); return $v; } /** Lê coordenadas de "lat, lng" ou de um link do Google Maps. */ function coords(string $s): array { $s = trim(rawurldecode($s)); $num = '(-?\d{1,3}(?:\.\d+)?)'; if (preg_match('/!3d' . $num . '!4d' . $num . '/', $s, $m) || preg_match('/(? (string)preg_replace('/@s\.whatsapp\.net$/', '', $jid), 'kind' => $kind, 'msg' => mb_substr($summary, 0, 140), 'at' => date('H:i')]; @session_start(); $_SESSION['hist'] = array_slice(array_merge([$item], (array)($_SESSION['hist'] ?? [])), 0, 30); session_write_close(); return $item; } /* ------------------------------------------------------------------ ações */ function action(string $a, array $in): never { $dev = '/devices/' . rawurlencode(WA_DEVICE); switch ($a) { case 'status': $info = ensure_device(); [$code, $d] = gw('GET', $dev . '/status'); if ($code >= 400) fail(gw_error($d, 'Falha ao ler o status')); $r = (array)($d['results'] ?? []); $jid = (string)($info['jid'] ?? ''); if ($jid === '' && !empty($info['phone_number'])) $jid = (string)$info['phone_number']; out(['ok' => true, 'logged' => !empty($r['is_logged_in']), 'connected' => !empty($r['is_connected']), 'name' => (string)($info['display_name'] ?? ''), 'phone' => $jid !== '' ? preg_replace('/[:@].*$/', '', $jid) : '']); case 'qr': ensure_device(); [$code, $d] = gw('GET', $dev . '/login'); if ($code >= 400) fail(gw_error($d, 'Não foi possível gerar o QR code')); $link = (string)($d['results']['qr_link'] ?? ''); $p = parse_url($link, PHP_URL_PATH); if (!$p) fail('Resposta sem QR code'); [$ic, $img] = gw('GET', $p, [], null, true); if ($ic >= 400 || $img === '') fail('Não foi possível baixar a imagem do QR code'); out(['ok' => true, 'img' => 'data:image/png;base64,' . base64_encode($img), 'duration' => (int)($d['results']['qr_duration'] ?? 30)]); case 'pair': ensure_device(); $phone = preg_replace('/\D+/', '', (string)($in['phone'] ?? '')); if (strlen($phone) < 8 || strlen($phone) > 15) fail('Digite o número com código do país, só dígitos (ex.: 50761234567).'); [$code, $d] = gw('POST', $dev . '/login/code', ['phone' => $phone]); if ($code >= 400) fail(gw_error($d, 'Não foi possível gerar o código')); $pc = (string)($d['results']['pair_code'] ?? ''); if ($pc === '') fail('O serviço não devolveu o código. Tente de novo.'); out(['ok' => true, 'code' => $pc]); case 'send': $type = (string)($in['type'] ?? 'text'); $jid = jid_of((string)($in['to'] ?? '')); switch ($type) { case 'text': $msg = text_in($in, 'message', 4000, 'Mensagem', true); call('/send/message', ['phone' => $jid, 'message' => $msg]); $sum = $msg; break; case 'location': [$lat, $lng] = coords((string)($in['coords'] ?? '')); call('/send/location', ['phone' => $jid, 'latitude' => $lat, 'longitude' => $lng]); $sum = $lat . ', ' . $lng; break; case 'contact': $name = text_in($in, 'name', 100, 'Nome do contato', true); $cp = preg_replace('/\D+/', '', (string)($in['phone'] ?? '')); if (strlen($cp) < 6 || strlen($cp) > 15) fail('Telefone do contato inválido. Use código do país + número (ex.: 50761234567).'); call('/send/contact', ['phone' => $jid, 'contact_name' => $name, 'contact_phone' => $cp]); $sum = $name . ' · +' . $cp; break; case 'link': $url = valid_url((string)($in['url'] ?? '')); $cap = text_in($in, 'caption', 3000, 'Texto'); call('/send/link', ['phone' => $jid, 'link' => $url, 'caption' => $cap]); $sum = $url; break; case 'poll': $q = text_in($in, 'question', 255, 'Pergunta', true); $opts = []; foreach ((array)($in['options'] ?? []) as $o) { $o = trim((string)$o); if ($o === '') continue; if (mb_strlen($o) > 100) fail('Opção muito longa (máximo 100 caracteres)'); if (in_array(mb_strtolower($o), array_map('mb_strtolower', $opts), true)) fail('Opção repetida: ' . $o); $opts[] = $o; } if (count($opts) < 2) fail('A enquete precisa de pelo menos 2 opções (uma por linha)'); if (count($opts) > 12) fail('Máximo de 12 opções'); $max = max(1, min(count($opts), (int)($in['max'] ?? 1))); call('/send/poll', ['phone' => $jid, 'question' => $q, 'options' => $opts, 'max_answer' => $max]); $sum = $q; break; default: out(['ok' => false, 'error' => 'Tipo de envio inválido'], 400); } out(['ok' => true, 'item' => add_hist($jid, $type, $sum)]); case 'media': @set_time_limit(360); $kind = (string)($in['kind'] ?? ''); $K = MEDIA[$kind] ?? null; if (!$K) out(['ok' => false, 'error' => 'Tipo de envio inválido'], 400); $jid = jid_of((string)($in['to'] ?? '')); $form = ['phone' => $jid]; if (($in['src'] ?? '') === 'url') { $u = valid_url((string)($in['url'] ?? '')); $form[$K['field'] . '_url'] = $u; $name = rawurldecode(basename((string)parse_url($u, PHP_URL_PATH))); if ($name === '' || $name === '/') $name = (string)parse_url($u, PHP_URL_HOST); } else { $f = $_FILES['file'] ?? null; $err = is_array($f) ? (int)($f['error'] ?? UPLOAD_ERR_NO_FILE) : UPLOAD_ERR_NO_FILE; if ($err === UPLOAD_ERR_NO_FILE) fail('Escolha um arquivo'); if ($err === UPLOAD_ERR_INI_SIZE || $err === UPLOAD_ERR_FORM_SIZE) fail('Arquivo grande demais para esta página (máximo ' . ini_mb('upload_max_filesize') . ' MB).'); if ($err !== UPLOAD_ERR_OK || !is_uploaded_file((string)$f['tmp_name'])) fail('Falha ao receber o arquivo. Tente de novo.'); $size = (int)$f['size']; if ($size <= 0) fail('O arquivo está vazio'); if ($size > $K['max'] * 1000000) fail($K['label'] . ' grande demais: o limite é ' . $K['max'] . ' MB.'); $name = basename(str_replace('\\', '/', (string)$f['name'])); if ($name === '' || $name === '.') $name = 'arquivo'; $mime = pick_mime($K, (string)$f['tmp_name'], $name, (string)($f['type'] ?? '')); if ($mime === null) { $tip = $kind === 'image' ? ' WebP ou GIF: envie como Figurinha ou como Arquivo.' : ($kind === 'video' ? ' Outros formatos: envie como Arquivo.' : ''); fail('Formato não aceito para ' . mb_strtolower($K['label']) . '. Use ' . $K['fmt'] . '.' . $tip); } $form[$K['field']] = new CURLFile((string)$f['tmp_name'], $mime, $name); } $cap = ''; if ($K['cap']) { $cap = text_in($in, 'caption', 3000, 'Legenda'); if ($cap !== '') $form['caption'] = $cap; } foreach ($K['opts'] as $o) { $v = (string)($in[$o] ?? ''); $form[$o] = ($v === '1' || $v === 'true' || $v === 'on') ? 'true' : 'false'; } [$code, $d] = gw('POST', $K['path'], [], null, false, $form, 330); if ($code >= 400) fail(gw_error($d, 'Falha ao enviar')); out(['ok' => true, 'item' => add_hist($jid, $kind, $name . ($cap !== '' ? ' · ' . $cap : ''))]); case 'hist': out(['ok' => true, 'items' => (array)($_SESSION['hist'] ?? [])]); case 'logout': [$code, $d] = gw('POST', $dev . '/logout'); if ($code >= 400) fail(gw_error($d, 'Falha ao desconectar')); out(['ok' => true]); case 'reconnect': [$code, $d] = gw('POST', $dev . '/reconnect'); if ($code >= 400) fail(gw_error($d, 'Falha ao reconectar')); out(['ok' => true]); } out(['ok' => false, 'error' => 'Ação inválida'], 400); } /* ------------------------------------------------------------------ páginas */ function css(): string { return <<<'CSS' :root{--bg:#0a0e17;--bg2:#0f1522;--card:#121a2a;--line:#1e2940;--line2:#283654;--tx:#e6ebf5;--mut:#8592ad;--dim:#56627d; --wa:#25d366;--wa2:#1da851;--cpu:#5b9cff;--bad:#ef4444;--warn:#f5b301;--r:14px;--mono:ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace} *{box-sizing:border-box;margin:0;padding:0} html,body{background:var(--bg);color:var(--tx);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,sans-serif} body{min-height:100vh;background-image:radial-gradient(900px 420px at 0 -10%,#12382a55,transparent),radial-gradient(900px 400px at 100% 0,#16244a44,transparent)} button,input,textarea{font:inherit;color:inherit} .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:40px;padding:0 16px;border-radius:10px;border:1px solid var(--line2);background:var(--bg2);color:var(--tx);font-weight:500;cursor:pointer;white-space:nowrap} .btn:hover:not(:disabled){border-color:var(--mut)} .btn:disabled{opacity:.5;cursor:default} .btn.pri{background:var(--wa);border-color:var(--wa);color:#04210f;font-weight:600} .btn.pri:hover:not(:disabled){background:var(--wa2);border-color:var(--wa2)} .btn.danger:hover:not(:disabled){border-color:var(--bad);color:#ff9a92} .btn.sm{height:32px;padding:0 12px;font-size:13px} .in{width:100%;background:var(--bg2);border:1px solid var(--line2);border-radius:10px;padding:10px 12px;outline:none;font-size:15px} .in:focus{border-color:var(--wa);box-shadow:0 0 0 3px #25d36622} textarea.in{min-height:120px;resize:vertical;line-height:1.5} textarea.in.sm{min-height:72px} label{display:block;font-size:12.5px;color:var(--mut);margin:14px 0 6px} CSS; } function logo(): string { return ''; } function icon(string $n): string { static $p = [ 'text' => '', 'image' => '', 'video' => '', 'audio' => '', 'file' => '', 'sticker' => '', 'location' => '', 'contact' => '', 'link' => '', 'poll' => '', 'upload' => '', 'x' => '', ]; return ''; } function page_login(string $err): void { $css = css(); $logo = logo(); $err = htmlspecialchars($err); echo << WhatsApp · Entrar

{$logo}WhatsApp

Painel de envio do servidor

{$err}
HTML; } function page_app(): void { $css = css(); $logo = logo(); $tok = $_SESSION['csrf']; $host = htmlspecialchars((string)gethostname()); $chips = ''; foreach (KIND_LABEL as $k => $l) { $chips .= ''; } $iUp = icon('upload'); $iX = icon('x'); $upMax = min(ini_mb('upload_max_filesize'), ini_mb('post_max_size')); $js = js(); echo << WhatsApp · {$host}
{$logo}WhatsApp {$host}
verificando…Sair

Conexão

Verificando a conexão…

Enviar

Conecte um número ao lado para poder enviar.
{$chips}
0 / 4000

Enviadas nesta sessão

Nada enviado ainda.

Ctrl+Enter envia. Para grupos, cole o ID completo do grupo (termina em @g.us). Upload pela página: até {$upMax} MB. Você também pode colar uma imagem (Ctrl+V).

HTML; } function js(): string { return <<<'JS' const TOKEN=window.WA_TOKEN; const $=id=>document.getElementById(id); const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); let state={logged:null},qrTimer=null,qrLeft=0,qrAuto=0,statusTimer=null,tab='qr'; function toast(m,er){const t=$('toast');t.textContent=m;t.className='toast show'+(er?' er':'');clearTimeout(t._h);t._h=setTimeout(()=>t.className='toast',er?6500:3000)} async function api(a,body){ let r;try{r=await fetch('?a='+a,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-Token':TOKEN},body:JSON.stringify(body||{})})} catch(e){throw new Error('Sem conexão com o servidor')} if(r.status===401){location.reload();throw new Error('Sessão expirada')} let d;try{d=await r.json()}catch(e){throw new Error('Resposta inválida do servidor')} if(!d.ok)throw new Error(d.error||'Erro');return d; } function upload(a,fd,onp){ return new Promise((res,rej)=>{ const x=new XMLHttpRequest(); x.open('POST','?a='+a);x.setRequestHeader('X-Token',TOKEN);x.responseType='json';x.timeout=600000; x.upload.onprogress=e=>{if(e.lengthComputable)onp(Math.round(e.loaded*100/e.total))}; x.upload.onload=()=>onp(100); x.onload=()=>{ if(x.status===401){location.reload();return rej(new Error('Sessão expirada'))} const d=x.response; if(!d)return rej(new Error(x.status===413?'Arquivo grande demais para o servidor':'Resposta inválida do servidor')); d.ok?res(d):rej(new Error(d.error||'Erro')); }; x.onerror=()=>rej(new Error('Sem conexão com o servidor')); x.ontimeout=()=>rej(new Error('O envio demorou demais')); x.send(fd); }); } function busy(b,on,label){if(on){b._l=b.innerHTML;b.disabled=true;b.innerHTML=''+(label||'')}else{b.disabled=false;if(b._l)b.innerHTML=b._l}} function setPill(cls,txt){const p=$('pill');p.className='pill '+cls;p.querySelector('span').textContent=txt} function fmtSize(n){return n<1024?n+' B':n<1048576?(n/1024).toFixed(0)+' KB':(n/1048576).toFixed(n<10485760?1:0)+' MB'} /* ---------------- conexão */ async function refresh(){ try{ const d=await api('status'); const was=state.logged;state=d; $('vLoading').hidden=true; $('vLogged').hidden=!d.logged;$('vLogin').hidden=d.logged; $('send').classList.toggle('disabled',!d.logged); if(d.logged){ setPill(d.connected?'on':'off',d.connected?'conectado':'conectado, sem rede'); $('meName').textContent=d.name||'WhatsApp conectado';$('mePhone').textContent=d.phone?'+'+d.phone:''; if(was===false){stopQr();$('pcBox').hidden=true;toast('Número conectado!')} }else setPill('off','desconectado'); }catch(e){$('vLoading').hidden=false;$('vLoading').textContent=e.message;setPill('off','serviço parado')} schedule(); } function schedule(){clearTimeout(statusTimer);const waiting=!state.logged&&(qrLeft>0||!$('pcBox').hidden);statusTimer=setTimeout(refresh,waiting?3000:15000)} function stopQr(){clearInterval(qrTimer);qrTimer=null;qrLeft=0;$('timer').textContent=''} async function genQr(auto){ const b=$('bQr');busy(b,true,' Gerando…'); try{ const d=await api('qr'); $('qr').classList.remove('exp'); $('qr').innerHTML='QR code
'; $('bQr2').onclick=()=>{qrAuto=0;genQr()}; qrLeft=d.duration||30;if(!auto)qrAuto=0; clearInterval(qrTimer); qrTimer=setInterval(()=>{ qrLeft--;$('timer').textContent=qrLeft>0?'expira em '+qrLeft+'s':''; if(qrLeft<=0){clearInterval(qrTimer);qrTimer=null; if(state.logged)return; if(qrAuto<4){qrAuto++;genQr(true)}else{$('qr').classList.add('exp');$('timer').textContent='QR code expirado'}} },1000); schedule(); }catch(e){toast(e.message,true)} finally{busy(b,false)} } $('bQr').onclick=()=>{qrAuto=0;genQr()}; document.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{ tab=b.dataset.t;document.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('on',x===b)); $('tQr').hidden=tab!=='qr';$('tCode').hidden=tab!=='code'; if(tab==='code'){stopQr();setTimeout(()=>$('pcPhone').focus(),30)} }); $('bPc').onclick=async()=>{ const b=$('bPc');busy(b,true,' Gerando…'); try{const d=await api('pair',{phone:$('pcPhone').value});$('pcCode').textContent=d.code;$('pcBox').hidden=false;schedule()} catch(e){toast(e.message,true)}finally{busy(b,false)} }; $('pcPhone').addEventListener('keydown',e=>{if(e.key==='Enter')$('bPc').click()}); $('bPcCopy').onclick=()=>{const t=$('pcCode').textContent;(navigator.clipboard&&window.isSecureContext?navigator.clipboard.writeText(t):Promise.reject()).then(()=>toast('Código copiado'),()=>{const r=document.createRange();r.selectNodeContents($('pcCode'));getSelection().removeAllRanges();getSelection().addRange(r);toast('Código selecionado: copie com Ctrl+C')})}; $('bOut').onclick=async()=>{ if(!confirm('Desconectar este número do servidor?'))return; const b=$('bOut');busy(b,true); try{await api('logout');toast('Número desconectado');state.logged=false;$('pcBox').hidden=true;$('qr').innerHTML='
Clique em Gerar QR code
';await refresh()} catch(e){toast(e.message,true)}finally{busy(b,false)} }; $('bRec').onclick=async()=>{const b=$('bRec');busy(b,true);try{await api('reconnect');toast('Reconectando…');setTimeout(refresh,2500)}catch(e){toast(e.message,true)}finally{busy(b,false)}}; /* ---------------- envio */ const KINDS={ text:{label:'Texto'}, image:{label:'Imagem',media:1,max:20,accept:'image/jpeg,image/png',fmt:'JPG ou PNG · até 20 MB',cap:1,url:'https://site.com/foto.jpg', opts:[['view_once','Ver uma vez'],['compress','Comprimir']]}, video:{label:'Vídeo',media:1,max:99,accept:'video/mp4,video/x-matroska,video/x-msvideo,.mp4,.mkv,.avi',fmt:'MP4, MKV ou AVI · até 100 MB',cap:1,url:'https://site.com/video.mp4', opts:[['view_once','Ver uma vez'],['compress','Comprimir'],['gif_playback','Tocar como GIF (sem som, em loop)']]}, audio:{label:'Áudio',media:1,max:50,accept:'audio/*,.mp3,.m4a,.ogg,.opus,.wav,.aac,.flac,.amr',fmt:'MP3, M4A, OGG, WAV, AAC, FLAC · até 50 MB',url:'https://site.com/audio.mp3', opts:[['ptt','Enviar como mensagem de voz (com a bolinha do microfone)']]}, file:{label:'Arquivo',media:1,max:50,accept:'',fmt:'PDF, Word, Excel, ZIP ou qualquer outro · até 50 MB',cap:1,url:'https://site.com/documento.pdf',opts:[]}, sticker:{label:'Figurinha',media:1,max:20,accept:'image/jpeg,image/png,image/webp,image/gif',fmt:'JPG, PNG, WebP ou GIF · vira figurinha automaticamente',url:'https://site.com/figura.png',opts:[]}, location:{label:'Local'},contact:{label:'Contato'},link:{label:'Link'},poll:{label:'Enquete'} }; const PREF_KEY='lkwa.prefs'; let kind='text',src='file',picked=null,pvUrl=null; function setKind(k){ if(!KINDS[k])k='text'; kind=k;const K=KINDS[k]; document.querySelectorAll('#types button').forEach(b=>b.classList.toggle('on',b.dataset.k===k)); document.querySelectorAll('.pane').forEach(p=>p.hidden=true); $(K.media?'p-media':'p-'+k).hidden=false; if(K.media){ $('fmt').textContent=K.fmt; $('file').accept=K.accept; $('murl').placeholder=K.url; $('urlHint').textContent='O servidor baixa o arquivo deste link e envia como '+K.label.toLowerCase()+'. O link precisa abrir direto o arquivo.'; $('capWrap').hidden=!K.cap; $('opts').innerHTML=K.opts.map(o=>'').join(''); if(picked&&picked.size>K.max*1e6){toast(K.label+': o limite é '+K.max+' MB',true);clearFile()} } $('bSend').textContent=k==='text'?'Enviar':'Enviar '+K.label.toLowerCase(); savePrefs(); } function setSrc(s){ src=s;document.querySelectorAll('#srcSeg button').forEach(b=>b.classList.toggle('on',b.dataset.s===s)); $('srcFile').hidden=s!=='file';$('srcUrl').hidden=s!=='url'; if(s==='url')setTimeout(()=>$('murl').focus(),30); savePrefs(); } function savePrefs(){try{localStorage.setItem(PREF_KEY,JSON.stringify({kind,src,to:$('to').value}))}catch(e){}} function loadPrefs(){let p={};try{p=JSON.parse(localStorage.getItem(PREF_KEY)||'{}')||{}}catch(e){}if(p.to)$('to').value=p.to;setSrc(p.src==='url'?'url':'file');setKind(p.kind||'text')} function clearFile(){ picked=null;$('file').value=''; if(pvUrl){URL.revokeObjectURL(pvUrl);pvUrl=null} $('dzEmpty').hidden=false;$('dzFile').hidden=true;$('pv').innerHTML='';$('fextra').innerHTML=''; } function setFile(f){ if(!f)return; const K=KINDS[kind]; if(K.media&&f.size>K.max*1e6){toast(K.label+' grande demais: o limite é '+K.max+' MB',true);return} clearFile();picked=f; $('dzEmpty').hidden=true;$('dzFile').hidden=false; $('fname').textContent=f.name||'arquivo';$('fsize').textContent=fmtSize(f.size)+(f.type?' · '+f.type:''); const ext=((f.name||'').split('.').pop()||'').slice(0,4); const t=f.type||''; if(t.startsWith('image/')||t.startsWith('video/')||t.startsWith('audio/'))pvUrl=URL.createObjectURL(f); if(t.startsWith('image/'))$('pv').innerHTML=''; else if(t.startsWith('video/'))$('pv').innerHTML=''; else $('pv').textContent=ext||'arq'; if(t.startsWith('audio/'))$('fextra').innerHTML=''; } document.querySelectorAll('#types button').forEach(b=>b.onclick=()=>setKind(b.dataset.k)); document.querySelectorAll('#srcSeg button').forEach(b=>b.onclick=()=>setSrc(b.dataset.s)); $('drop').onclick=e=>{if(e.target.closest('#fclear')||e.target.closest('audio')||e.target.closest('#dzFile'))return;$('file').click()}; $('fclear').onclick=e=>{e.stopPropagation();clearFile()}; $('file').onchange=()=>setFile($('file').files[0]); ['dragenter','dragover'].forEach(ev=>$('drop').addEventListener(ev,e=>{e.preventDefault();$('drop').classList.add('over')})); ['dragleave','drop'].forEach(ev=>$('drop').addEventListener(ev,e=>{e.preventDefault();$('drop').classList.remove('over')})); $('drop').addEventListener('drop',e=>{const f=e.dataTransfer&&e.dataTransfer.files[0];if(f)setFile(f)}); document.addEventListener('paste',e=>{ const f=e.clipboardData&&e.clipboardData.files&&e.clipboardData.files[0]; if(!f||$('send').classList.contains('disabled'))return; e.preventDefault(); if(!KINDS[kind].media)setKind(f.type.startsWith('image/')?'image':f.type.startsWith('video/')?'video':f.type.startsWith('audio/')?'audio':'file'); setSrc('file');setFile(f);toast('Arquivo colado: '+(f.name||f.type)); }); $('to').addEventListener('change',savePrefs); function resetFields(k){ if(k==='text'){$('msg').value='';$('count').textContent='0 / 4000';$('msg').focus()} else if(KINDS[k].media){clearFile();$('murl').value='';$('cap').value=''} else if(k==='location')$('coords').value=''; else if(k==='contact'){$('cName').value='';$('cPhone').value=''} else if(k==='link'){$('lUrl').value='';$('lCap').value=''} else if(k==='poll'){$('pQ').value='';$('pOpts').value='';$('pMax').value=1} } let sending=false; async function send(){ if(sending)return; const to=$('to').value.trim(); if(!to){toast('Digite o número de destino',true);$('to').focus();return} const k=kind,K=KINDS[k],b=$('bSend'); let d; try{ sending=true; if(K.media){ const fd=new FormData(); fd.append('kind',k);fd.append('to',to);fd.append('src',src); if(src==='url'){const u=$('murl').value.trim();if(!u){$('murl').focus();throw new Error('Cole o link do arquivo')}fd.append('url',u)} else{if(!picked)throw new Error('Escolha um arquivo');fd.append('file',picked,picked.name||'arquivo')} if(K.cap)fd.append('caption',$('cap').value); document.querySelectorAll('#opts input').forEach(i=>fd.append(i.name,i.checked?'1':'0')); busy(b,true,' Enviando…'); d=await upload('media',fd,p=>{$('prog').textContent=src==='url'?'Baixando e enviando…':(p<100?'Enviando '+p+'%':'Enviando para o WhatsApp…')}); }else{ const body={type:k,to}; if(k==='text')body.message=$('msg').value; else if(k==='location')body.coords=$('coords').value; else if(k==='contact'){body.name=$('cName').value;body.phone=$('cPhone').value} else if(k==='link'){body.url=$('lUrl').value;body.caption=$('lCap').value} else if(k==='poll'){body.question=$('pQ').value;body.options=$('pOpts').value.split('\n');body.max=+$('pMax').value||1} busy(b,true,' Enviando…'); d=await api('send',body); } hist.unshift(d.item);hist=hist.slice(0,30);renderHist(hist); resetFields(k);savePrefs(); const fem={text:1,image:1,sticker:1,location:1,poll:1}[k],what={text:'Mensagem',location:'Localização'}[k]||K.label; toast(what+(fem?' enviada':' enviado')+' para '+(d.item.to.includes('@')?d.item.to:'+'+d.item.to)); }catch(e){toast(e.message,true)} finally{sending=false;busy(b,false);$('prog').textContent=''} } $('bSend').onclick=send; $('msg').addEventListener('input',()=>$('count').textContent=$('msg').value.length+' / 4000'); $('send').addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();send()}}); ['coords','cPhone','lUrl','murl'].forEach(id=>$(id).addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.ctrlKey){e.preventDefault();send()}})); let hist=[]; function renderHist(items){ $('hist').innerHTML=items.length?items.map(i=>{ const k=i.kind||'text',lab=(KINDS[k]||KINDS.text).label; return '
'+esc(i.at)+''+esc(i.to.includes('@')?i.to:'+'+i.to)+''+esc(lab)+''+esc(i.msg)+'
'; }).join(''):'
Nada enviado ainda.
'; } loadPrefs(); api('hist').then(d=>{hist=d.items;renderHist(hist)}).catch(()=>{}); document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()}); refresh(); JS; } LK_WA_PAGE_EOF chown root:www-data "$WDIR/index.php"; chmod 644 "$WDIR/index.php" # limites de upload só desta página (PHP-FPM lê o .user.ini da pasta) cat > "$WDIR/.user.ini" <<'EOF' upload_max_filesize = 100M post_max_size = 101M max_file_uploads = 5 max_execution_time = 360 max_input_time = 360 memory_limit = 256M EOF chown root:www-data "$WDIR/.user.ini"; chmod 644 "$WDIR/.user.ini" # receptor das mensagens do WhatsApp para o Guardião (só o GOWA local chama) cat > "$WDIR/hook.php" <<'LK_WA_HOOK_EOF' 262144) done(413); $sig = (string)($_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? ''); if (!hash_equals('sha256=' . hash_hmac('sha256', $raw, WA_HOOK_SECRET), $sig)) done(401); $d = json_decode($raw, true); if (!is_array($d) || ($d['event'] ?? '') !== 'message') done(); $p = is_array($d['payload'] ?? null) ? $d['payload'] : []; $dir = '/var/lib/lk-guard/inbox'; $chat = (string)($p['chat_id'] ?? ''); $from = (string)($p['from'] ?? ''); $body = trim((string)($p['body'] ?? '')); $dev = (string)preg_replace('/[:@].*$/', '', (string)($d['device_id'] ?? '')); // diagnóstico: última mensagem recebida (sem o texto) if (is_dir($dir)) @file_put_contents($dir . '/.ultima', date('c') . " chat=$chat from=$from me=" . (empty($p['is_from_me']) ? 0 : 1) . " device=$dev len=" . mb_strlen($body) . "\n"); if (str_ends_with($chat, '@g.us')) done(); // grupos: ignora if ($body === '' || mb_strlen($body) > 200) done(); if (!empty($p['is_from_me'])) { // Mensagem enviada pelo próprio número do servidor. Só vale na conversa "Mensagens para mim" // (quando o servidor usa o número do dono) e só se for um comando curto — assim os avisos // que o próprio Guardião manda nunca viram comando. $chatNum = (string)preg_replace('/[:@].*$/', '', $chat); if ($dev === '' || $chatNum !== $dev || !str_ends_with($chat, '@s.whatsapp.net')) done(); if (mb_strlen($body) > 40 || !preg_match('/^(status|temp|temperatura|info|ajuda|menu|comandos|help|\?|reinicia|reiniciar|reboot|desliga|desligar|poweroff|shutdown|cancelar)(\s|$)/iu', $body)) done(); $from = $dev . '@s.whatsapp.net'; } else { if (!str_ends_with($from, '@s.whatsapp.net') && str_ends_with($chat, '@s.whatsapp.net')) $from = $chat; if (!str_ends_with($from, '@s.whatsapp.net')) { // remetente só com LID (o WhatsApp ainda não informou o número): o Guardião descobre qual é $lid = (string)($p['from_lid'] ?? ''); if (!str_ends_with($lid, '@lid')) $lid = str_ends_with($from, '@lid') ? $from : (str_ends_with($chat, '@lid') ? $chat : ''); if ($lid === '') done(); $from = 'lid:' . preg_replace('/[:@].*$/', '', $lid); } } if (!is_dir($dir) || count(glob($dir . '/*.json') ?: []) > 50) done(); $fromOut = str_starts_with($from, 'lid:') ? $from : preg_replace('/[:@].*$/', '', $from); @file_put_contents($dir . '/' . sprintf('%.4f', microtime(true)) . '-' . bin2hex(random_bytes(4)) . '.json', json_encode(['from' => $fromOut, 'text' => $body, 'at' => time()], JSON_UNESCAPED_UNICODE), LOCK_EX); done(); LK_WA_HOOK_EOF chown root:www-data "$WDIR/hook.php"; chmod 644 "$WDIR/hook.php" cat > "$SITE" < ServerName whatsapp-panel DocumentRoot $WDIR DirectoryIndex index.php Options -Indexes AllowOverride None Require ip 127.0.0.1 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 Require all denied Require ip 127.0.0.1 ::1 SetHandler "proxy:unix:/run/php/php$PHPV-fpm.sock|fcgi://localhost" ProxyTimeout 400 LimitRequestBody 110000000 ErrorLog \${APACHE_LOG_DIR}/whatsapp-panel_error.log CustomLog \${APACHE_LOG_DIR}/whatsapp-panel_access.log combined EOF a2enmod proxy_fcgi setenvif >/dev/null 2>&1 a2ensite whatsapp-panel >/dev/null if ! apache2ctl configtest >/dev/null 2>&1; then apache2ctl configtest a2dissite whatsapp-panel >/dev/null 2>&1 die "Configuração do Apache com erro. Página não ativada." fi systemctl restart "php$PHPV-fpm" 2>/dev/null systemctl reload apache2 || systemctl restart apache2 if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then ufw allow from 192.168.0.0/16 to any port "$PPORT" proto tcp >/dev/null && info "Porta $PPORT liberada no UFW para a rede local" fi # ---------------------------------------------------------------- Guardião (temperatura + comandos pelo WhatsApp) info "Instalando o Guardião..." mkdir -p /opt/lk-guard /var/lib/lk-guard/inbox cat > /opt/lk-guard/guard.php <<'LK_GUARD_EOF' = WARN_TEMP por WARN_MIN minutos: avisa no WhatsApp com um código * para REINICIAR / DESLIGAR. Repete a cada REPEAT_MIN minutos enquanto continuar quente. * - Temperatura >= CRIT_TEMP: avisa e desliga sozinho depois de CRIT_MIN minutos * (se AUTO_OFF=1), a menos que alguém responda CANCELAR . * - Comandos aceitos só dos números em NUMBERS: STATUS, REINICIAR, DESLIGAR, CANCELAR, AJUDA. * * Mensagens chegam pelo hook.php da página do WhatsApp, que grava em /var/lib/lk-guard/inbox. * Roda como root (systemd: lk-guard.service). */ declare(strict_types=1); error_reporting(E_ALL & ~E_DEPRECATED); define('CONF', getenv('LKG_CONF') ?: '/etc/lk-guard.conf'); define('GOWA_ENV', getenv('LKG_GOWA_ENV') ?: '/etc/gowa/gowa.env'); define('DIR', getenv('LKG_DIR') ?: '/var/lib/lk-guard'); define('INBOX', DIR . '/inbox'); define('STATE', DIR . '/state.json'); define('DRY', getenv('LKG_DRY') === '1'); // testes: não reinicia/desliga de verdade define('TEMP_FILE', getenv('LKG_TEMP_FILE') ?: ''); // testes: lê a temperatura deste arquivo define('TEMP_EVERY', max(1, (int)(getenv('LKG_TEMP_EVERY') ?: 10))); define('DEVICE', 'lk'); function logm(string $m): void { fwrite(STDERR, '[' . date('H:i:s') . "] $m\n"); } /** Lê arquivos KEY=valor (ignora linhas vazias e comentários com # ou ;). */ function read_kv(string $file): array { $out = []; foreach (@file($file, FILE_IGNORE_NEW_LINES) ?: [] as $l) { $l = trim($l); if ($l === '' || $l[0] === '#' || $l[0] === ';' || !str_contains($l, '=')) continue; [$k, $v] = explode('=', $l, 2); $out[trim($k)] = trim(trim($v), "\"'"); } return $out; } function conf(): array { $d = ['NUMBERS' => '', 'WARN_TEMP' => 90, 'WARN_MIN' => 1, 'CRIT_TEMP' => 97, 'CRIT_MIN' => 2, 'AUTO_OFF' => 1, 'REPEAT_MIN' => 15, 'BOT' => 1]; $c = read_kv(CONF); foreach ($d as $k => $v) { if (!isset($c[$k]) || trim((string)$c[$k], "\"' ") === '') continue; $val = trim((string)$c[$k], "\"' "); $d[$k] = is_int($v) ? (int)$val : $val; } // LK-BOT-PATCH2: "Repetir o mesmo alerta a cada N min" do LK Monitor vale também aqui $lkm = json_decode((string)@file_get_contents('/var/lib/lkmonitor/settings.json'), true); if (is_array($lkm) && (int)($lkm['repeat'] ?? 0) >= 1) $d['REPEAT_MIN'] = (int)$lkm['repeat']; $nums = []; foreach (explode(',', (string)$d['NUMBERS']) as $n) { $n = preg_replace('/\D+/', '', $n); if (strlen($n) >= 8 && strlen($n) <= 15) $nums[] = $n; } $d['nums'] = array_values(array_unique($nums)); return $d; } function gowa(): array { $e = read_kv(GOWA_ENV); return ['port' => trim((string)($e['APP_PORT'] ?? '')), 'user' => trim((string)($e['GOWA_USER'] ?? '')), 'pass' => trim((string)($e['GOWA_PASS'] ?? ''))]; } function gowa_call(string $method, string $path, ?array $body = null, int $timeout = 20): array { $g = gowa(); if ($g['port'] === '') return [0, null]; $ch = curl_init('http://127.0.0.1:' . $g['port'] . $path); $h = ['X-Device-Id: ' . DEVICE, 'Accept: application/json']; if ($body !== null) { $h[] = 'Content-Type: application/json'; curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $h, CURLOPT_USERPWD => $g['user'] . ':' . $g['pass'], CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 5]); $r = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); return [$r === false ? 0 : $code, $r === false ? null : json_decode((string)$r, true)]; } function send(string $num, string $text): bool { // LK-BOT-PATCH: BOT=0 em /etc/lk-guard.conf deixa o bot mudo (o desligamento automático continua) if ((int)(conf()['BOT'] ?? 1) !== 1) { logm('Bot desligado (BOT=0), mensagem não enviada: ' . str_replace("\n", ' | ', $text)); return false; } [$code, $d] = gowa_call('POST', '/send/message', ['phone' => $num . '@s.whatsapp.net', 'message' => $text]); if ($code < 200 || $code >= 300) { logm("Falha ao enviar para $num (HTTP $code): " . (is_array($d) ? (string)($d['message'] ?? '') : 'sem resposta')); return false; } return true; } function whatsapp_ready(): bool { [$code, $d] = gowa_call('GET', '/devices/' . DEVICE . '/status', null, 8); return $code === 200 && !empty($d['results']['is_logged_in']) && !empty($d['results']['is_connected']); } /** * O WhatsApp às vezes identifica o remetente só pelo LID (um código interno), sem o número. * Descobre a qual número autorizado o LID pertence, consultando o GOWA, e guarda o resultado. */ function lid_to_number(string $lid, array $nums): string { if ($lid === '') return ''; $file = DIR . '/lids.json'; $map = json_decode((string)@file_get_contents($file), true); if (!is_array($map)) $map = []; if (isset($map[$lid]) && in_array($map[$lid], $nums, true)) return $map[$lid]; $found = ''; // 1) LID -> número [$code, $d] = gowa_call('GET', '/user/info?phone=' . rawurlencode($lid . '@lid'), null, 15); if ($code === 200) $found = (string)preg_replace('/\D+/', '', (string)($d['results']['resolved_phone'] ?? '')); // 2) número -> LID, para cada número autorizado (a 1ª consulta faz o GOWA aprender a ligação) if ($found === '') { foreach ($nums as $n) { for ($try = 0; $try < 2 && $found === ''; $try++) { [$code, $d] = gowa_call('GET', '/user/info?phone=' . rawurlencode($n . '@s.whatsapp.net'), null, 15); $rl = $code === 200 ? (string)preg_replace('/[:@].*$/', '', (string)($d['results']['resolved_lid'] ?? '')) : ''; if ($rl !== '') { $map[$rl] = $n; if ($rl === $lid) $found = $n; break; } } if ($found !== '') break; } } if ($found !== '') { $map[$lid] = $found; logm("LID $lid identificado como +$found"); } @file_put_contents($file, json_encode($map)); @chmod($file, 0600); return $found; } function cpu_temp(): ?float { if (TEMP_FILE !== '') { $v = trim((string)@file_get_contents(TEMP_FILE)); return $v === '' ? null : (float)$v; } $best = null; foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $h) { $name = trim((string)@file_get_contents("$h/name")); if (!in_array($name, ['coretemp', 'k10temp', 'zenpower', 'cpu_thermal', 'soc_thermal'], true)) continue; foreach (glob("$h/temp*_input") ?: [] as $f) { $v = (int)@file_get_contents($f); if ($v > 0 && ($best === null || $v / 1000 > $best)) $best = $v / 1000; } } if ($best !== null) return $best; foreach (glob('/sys/class/thermal/thermal_zone*') ?: [] as $z) { if (!preg_match('/x86_pkg_temp|cpu|soc|acpitz/i', trim((string)@file_get_contents("$z/type")))) continue; $v = (int)@file_get_contents("$z/temp"); if ($v > 0 && ($best === null || $v / 1000 > $best)) $best = $v / 1000; } return $best; } function fmt_dur(int $s): string { $d = intdiv($s, 86400); $h = intdiv($s % 86400, 3600); $m = intdiv($s % 3600, 60); return ($d ? "{$d}d " : '') . ($d || $h ? "{$h}h " : '') . "{$m}min"; } function host(): string { return (string)gethostname(); } function status_text(): string { $t = cpu_temp(); $la = explode(' ', trim((string)@file_get_contents('/proc/loadavg'))); $cores = max(1, preg_match_all('/^processor\s*:/m', (string)@file_get_contents('/proc/cpuinfo'))); $mi = []; foreach (explode("\n", (string)@file_get_contents('/proc/meminfo')) as $l) { if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mi[$m[1]] = (int)$m[2]; } $tot = ($mi['MemTotal'] ?? 0) / 1048576; $used = $tot - ($mi['MemAvailable'] ?? 0) / 1048576; $dt = @disk_total_space('/') ?: 0; $df = @disk_free_space('/') ?: 0; $up = (int)(float)@file_get_contents('/proc/uptime'); return "📊 *" . host() . "*\n" . "🌡️ CPU: " . ($t !== null ? round($t) . ' °C' : 'sem sensor') . "\n" . "⚙️ Carga: " . ($la[0] ?? '?') . " ($cores núcleos)\n" . "🧠 RAM: " . number_format($used, 1, ',', '') . " de " . number_format($tot, 1, ',', '') . " GB\n" . "💾 Disco: " . ($dt > 0 ? round(100 * ($dt - $df) / $dt) : '?') . "% usado\n" . "⏱️ Ligado há " . fmt_dur($up); } function help_text(): string { return "🤖 *" . host() . "* — comandos:\n" . "*STATUS* – temperatura, CPU, RAM e disco\n" . "*REINICIAR* – reinicia o servidor (pede confirmação)\n" . "*DESLIGAR* – desliga o servidor (pede confirmação)\n" . "*CANCELAR * – cancela o desligamento automático"; } function norm_text(string $s): string { $s = mb_strtolower(trim($s)); $s = strtr($s, ['á' => 'a', 'à' => 'a', 'â' => 'a', 'ã' => 'a', 'é' => 'e', 'ê' => 'e', 'í' => 'i', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ú' => 'u', 'ü' => 'u', 'ç' => 'c']); $s = (string)preg_replace('/[^a-z0-9 ]+/', ' ', $s); return trim((string)preg_replace('/\s+/', ' ', $s)); } final class Guard { private ?int $warnSince = null; private bool $alerted = false; private int $lastAlert = 0; private int $lastCritMsg = 0; // LK-BOT-PATCH: anti-spam do aviso crítico private bool $critNotified = false; private ?int $autoOffAt = null; private int $noAutoOffUntil = 0; private ?array $code = null; // ['code' => '1234', 'until' => ts] private ?float $temp = null; private int $lastTempCheck = 0; public function run(): void { if (!is_dir(INBOX)) @mkdir(INBOX, 0730, true); logm('Guardião iniciado em ' . host() . (DRY ? ' (modo teste)' : '')); $this->afterBoot(); while (true) { $c = conf(); $this->inbox($c); if (time() - $this->lastTempCheck >= TEMP_EVERY) { $this->lastTempCheck = time(); $this->checkTemp($c); } if ($this->autoOffAt !== null && time() >= $this->autoOffAt) $this->autoOff($c); sleep(getenv('LKG_TICK') !== false ? max(1, (int)getenv('LKG_TICK')) : 2); } } /* Avisa que o servidor voltou depois de um REINICIAR pedido pelo WhatsApp. */ private function afterBoot(): void { $st = json_decode((string)@file_get_contents(STATE), true); if (!is_array($st) || empty($st['rebooting'])) return; @unlink(STATE); if (time() - (int)$st['rebooting'] > 3600) return; $c = conf(); for ($i = 0; $i < 60 && !whatsapp_ready(); $i++) sleep(3); $up = (int)(float)@file_get_contents('/proc/uptime'); foreach ($c['nums'] as $n) send($n, "✅ *" . host() . "* ligado de novo (reiniciou há " . fmt_dur($up) . ")."); } private function newCode(int $minutes): string { if ($this->code !== null && $this->code['until'] - time() >= 120) { $this->code['until'] = max($this->code['until'], time() + $minutes * 60); return $this->code['code']; } $this->code = ['code' => (string)random_int(1000, 9999), 'until' => time() + $minutes * 60]; return $this->code['code']; } private function codeOk(string $given): bool { return $this->code !== null && time() <= $this->code['until'] && hash_equals($this->code['code'], $given); } private function broadcast(array $c, string $text): void { if (!$c['nums']) { logm('Nenhum número configurado em ' . CONF . ': aviso não enviado: ' . str_replace("\n", ' | ', $text)); return; } foreach ($c['nums'] as $n) send($n, $text); } /* LK-BOT-PATCH: aviso crítico no máximo 1x a cada REPEAT_MIN minutos (a temperatura subindo e descendo em volta do limite mandava dezenas de mensagens). */ private function critMsgOk(array $c, int $now): bool { if ($now - $this->lastCritMsg < max(1, (int)$c['REPEAT_MIN']) * 60) { logm('Aviso crítico suprimido (anti-spam)'); return false; } $this->lastCritMsg = $now; $this->critNotified = true; return true; } private function checkTemp(array $c): void { $t = cpu_temp(); if ($t === null) return; $this->temp = $t; $now = time(); $tt = round($t) . ' °C'; // crítico: desliga sozinho (vem antes do aviso para não mandar duas mensagens) if ((int)$c['AUTO_OFF'] === 1 && $t >= $c['CRIT_TEMP']) { if ($this->autoOffAt === null && $now >= $this->noAutoOffUntil) { $code = $this->newCode(30); $this->autoOffAt = $now + max(0, (int)$c['CRIT_MIN']) * 60; $this->alerted = true; $this->lastAlert = $now; $this->warnSince ??= $now; logm("Temperatura crítica: $tt — desligamento automático agendado"); if ($this->critMsgOk($c, $now)) $this->broadcast($c, "🛑 *" . host() . "* chegou a *$tt*!\n\n" . "Vou *DESLIGAR* em {$c['CRIT_MIN']} min para proteger o notebook, se não baixar.\n" . "Responda *CANCELAR $code* para impedir, ou *DESLIGAR $code* para desligar agora."); } } elseif ($this->autoOffAt !== null && $t <= $c['CRIT_TEMP'] - 3) { $this->autoOffAt = null; logm("Temperatura baixou: $tt — desligamento automático cancelado"); if ($this->critNotified) { $this->critNotified = false; $this->broadcast($c, "👍 *" . host() . "*: temperatura baixou para *$tt*. Desligamento automático cancelado."); } } // aviso if ($t >= $c['WARN_TEMP']) { $this->warnSince ??= $now; $due = !$this->alerted ? ($now - $this->warnSince >= $c['WARN_MIN'] * 60 && $now - $this->lastAlert >= $c['REPEAT_MIN'] * 60) : $now - $this->lastAlert >= $c['REPEAT_MIN'] * 60; if ($due && $this->autoOffAt === null) { $code = $this->newCode(30); $this->alerted = true; $this->lastAlert = $now; logm("Aviso de temperatura: $tt"); $this->broadcast($c, "🔥 *" . host() . "* está em *$tt* (limite {$c['WARN_TEMP']} °C).\n\n" . "Responda com:\n*REINICIAR $code* – reinicia o servidor\n*DESLIGAR $code* – desliga o servidor\n*STATUS* – mostra como ele está\n\n" . "O código vale 30 min."); } } elseif ($t <= $c['WARN_TEMP'] - 5) { $this->warnSince = null; if ($this->alerted) { $this->alerted = false; logm("Temperatura normal: $tt"); $this->broadcast($c, "✅ *" . host() . "*: temperatura voltou ao normal (*$tt*)."); } } } private function autoOff(array $c): void { $this->autoOffAt = null; $this->noAutoOffUntil = time() + 600; // se o poweroff falhar, não repete antes de 10 min $tt = $this->temp !== null ? round($this->temp) . ' °C' : '?'; logm("Desligando por temperatura ($tt)"); $this->broadcast($c, "⏻ *" . host() . "* continua em *$tt*: desligando agora para proteger o notebook.\nPara ligar de novo, use o botão de energia."); $this->power(false); } private function power(bool $reboot): void { if ($reboot) @file_put_contents(STATE, json_encode(['rebooting' => time()])); if (DRY) { logm('[TESTE] ' . ($reboot ? 'systemctl reboot' : 'systemctl poweroff')); return; } sleep(2); exec($reboot ? '/bin/systemctl reboot' : '/bin/systemctl poweroff'); } private function inbox(array $c): void { $files = glob(INBOX . '/*.json') ?: []; sort($files); foreach ($files as $f) { $ok = is_file($f) && !is_link($f) && filesize($f) <= 8192; $raw = $ok ? (string)@file_get_contents($f) : ''; @unlink($f); $m = json_decode($raw, true); if (!is_array($m)) continue; if (time() - (int)($m['at'] ?? 0) > 300) continue; // mensagem velha: ignora $rawFrom = (string)($m['from'] ?? ''); $text = (string)($m['text'] ?? ''); if (str_starts_with($rawFrom, 'lid:')) { $lid = (string)preg_replace('/\D+/', '', substr($rawFrom, 4)); $from = lid_to_number($lid, $c['nums']); if ($from === '') { logm("Mensagem de LID $lid sem número conhecido ignorada"); continue; } } else { $from = (string)preg_replace('/\D+/', '', $rawFrom); } if (!in_array($from, $c['nums'], true)) { logm("Mensagem de número não autorizado ignorada: $from"); continue; } $this->command($c, $from, $text); } } private function command(array $c, string $from, string $text): void { $raw = trim($text); $n = norm_text($raw); $parts = explode(' ', $n); $w = $parts[0] ?? ''; $arg = $parts[1] ?? ''; if ($raw === '?' || in_array($w, ['ajuda', 'menu', 'comandos', 'help'], true)) { send($from, help_text()); return; } if (in_array($w, ['status', 'temp', 'temperatura', 'info'], true)) { send($from, status_text()); return; } if (in_array($w, ['reiniciar', 'reboot', 'reinicia'], true)) { $this->power_cmd($c, $from, true, $arg); return; } if (in_array($w, ['desligar', 'desliga', 'poweroff', 'shutdown'], true)) { $this->power_cmd($c, $from, false, $arg); return; } if ($w === 'cancelar') { if ($this->autoOffAt === null) { send($from, "Não há desligamento automático agendado."); } elseif (!$this->codeOk($arg)) { send($from, "Código inválido. Responda *CANCELAR* seguido do código do último aviso."); } else { $this->autoOffAt = null; $this->noAutoOffUntil = time() + 1800; logm("Desligamento automático cancelado por +$from"); $this->broadcast($c, "🟡 Desligamento automático de *" . host() . "* cancelado por +$from. Volto a vigiar em 30 min."); } return; } // qualquer outra mensagem: não responde (o número pode estar só conversando) } private function power_cmd(array $c, string $from, bool $reboot, string $arg): void { $word = $reboot ? 'REINICIAR' : 'DESLIGAR'; if ($arg === '') { $code = $this->newCode(5); send($from, "Para confirmar, responda:\n*$word $code*\n\n" . ($reboot ? "O servidor e os sites ficam fora do ar por cerca de 1 minuto." : "⚠️ Para ligar de novo, só pelo botão de energia do notebook.") . "\nO código vale 5 min."); return; } if (!$this->codeOk($arg)) { send($from, "Código inválido ou vencido. Mande *$word* para receber um novo."); return; } $this->code = null; $this->autoOffAt = null; logm(($reboot ? 'Reiniciando' : 'Desligando') . " a pedido de +$from"); $this->broadcast($c, $reboot ? "🔄 Reiniciando *" . host() . "* a pedido de +$from. Aviso quando ele voltar." : "⏻ Desligando *" . host() . "* a pedido de +$from. Para ligar de novo, use o botão de energia."); $this->power($reboot); } } if (PHP_SAPI !== 'cli') exit(1); if (in_array($argv[1] ?? '', ['status', '--status'], true)) { echo status_text(), "\n"; exit(0); } if (in_array($argv[1] ?? '', ['teste', '--test'], true)) { $c = conf(); if (!$c['nums']) { fwrite(STDERR, "Nenhum número configurado em " . CONF . "\n"); exit(1); } $ok = true; foreach ($c['nums'] as $n) $ok = send($n, "✅ Guardião de *" . host() . "* funcionando.\n\n" . help_text()) && $ok; echo $ok ? "Mensagem de teste enviada para: " . implode(', ', $c['nums']) . "\n" : "Falha ao enviar (veja acima)\n"; exit($ok ? 0 : 1); } (new Guard())->run(); LK_GUARD_EOF chown -R root:root /opt/lk-guard; chmod 755 /opt/lk-guard; chmod 644 /opt/lk-guard/guard.php chown root:root /var/lib/lk-guard; chmod 711 /var/lib/lk-guard chown root:www-data /var/lib/lk-guard/inbox; chmod 770 /var/lib/lk-guard/inbox [[ -f $GCONF ]] || configure_guard cat > "$GSVC" </dev/null 2>&1 systemctl restart lk-guard # GOWA entrega as mensagens recebidas para a página (hook.php), que repassa ao Guardião CHG=0 envset WHATSAPP_WEBHOOK "http://127.0.0.1:$PPORT/hook.php" && CHG=1 envset WHATSAPP_WEBHOOK_EVENTS "message" && CHG=1 if [[ $CHG == 1 ]]; then info "Ativando o recebimento de mensagens no GOWA..." systemctl restart gowa for i in $(seq 1 30); do [[ $(curl -s -o /dev/null -w '%{http_code}' -u "$(envget GOWA_USER):$(envget GOWA_PASS)" "http://127.0.0.1:$PORT/devices") == 200 ]] && break sleep 1 done fi sleep 2 if systemctl is-active --quiet lk-guard; then ok "Guardião rodando"; else warn "O Guardião não iniciou. Veja: journalctl -u lk-guard -n 30 --no-pager"; fi lkmon_link echo echo "${G}=============================================${N}" echo "${G} WhatsApp instalado!${N}" echo "${G}=============================================${N}" show_info echo " Ver de novo estes dados: sudo lk whatsapp info" echo " Trocar senha da página: sudo lk whatsapp senha" echo " Números do Guardião: sudo lk whatsapp guardiao" echo " Remover: sudo lk whatsapp remover" echo LK_WHATSAPP_INSTALLER_EOF chmod 755 "$SHARE/install-whatsapp.sh" echo "${C}[..]${N} Instalando dependências básicas (curl, ipset, iptables)..." export DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || echo " aviso: apt-get update falhou, seguindo assim mesmo" apt-get install -y -qq curl ca-certificates ipset iptables conntrack >/dev/null 2>&1 || echo " aviso: algumas dependências não instalaram (o LK instala quando precisar)" # Restaura os IPs bloqueados a cada boot cat > /etc/systemd/system/lk-firewall.service <<'SVC' [Unit] Description=LK - restaurar IPs bloqueados After=network-pre.target docker.service Wants=network-pre.target [Service] Type=oneshot ExecStart=/usr/bin/lk fw-restore RemainAfterExit=yes [Install] WantedBy=multi-user.target SVC systemctl daemon-reload || true systemctl enable lk-firewall.service >/dev/null 2>&1 || true /usr/bin/lk fw-restore >/dev/null 2>&1 || true # Sites atrás do Funnel / Cloudflare Tunnel: PHP enxerga https (sem "Mixed Content") if command -v apache2ctl >/dev/null && [[ ! -e /etc/apache2/conf-enabled/lk-proxy-https.conf ]]; then /usr/bin/lk proxy-https >/dev/null 2>&1 && echo "${G}[OK]${N} Sites atrás do Tailscale Funnel / Cloudflare abrem com CSS (https)" fi # Tailscale instalado: o Apache não pode pedir a 443 em todos os IPs (o Funnel usa a 443 no IP do Tailscale) if command -v tailscale >/dev/null && command -v apache2ctl >/dev/null && ! grep -qsE '^\s*Include\s+lk-listen-443\.conf' /etc/apache2/ports.conf; then echo "${C}[..]${N} Ajustando o Apache para dividir a porta 443 com o Tailscale..." /usr/bin/lk apache-443 || echo " aviso: não deu para ajustar agora (depois: sudo lk apache-443)" fi # Painel já instalado: atualiza junto, mantendo porta, senha, usuário do terminal e dados if [[ -f /var/www/lkmonitor/config.php && -f /etc/apache2/sites-available/lkmonitor.conf ]]; then echo "${C}[..]${N} Atualizando o LK Monitor (painel web) já instalado..." bash "$SHARE/install-lk-monitor.sh" atualizar || echo " aviso: não foi possível atualizar o painel agora (rode: sudo lk -> opção 33 -> 1)" elif [[ -t 0 && ${LK_NO_SETUP:-0} != 1 ]]; then # primeira vez: oferece instalar tudo de uma vez (LAMP + painel com senha temporária) echo read -rp "Instalar o painel agora (Apache, PHP, MariaDB e o painel web)? [S/n]: " LKS if [[ ! $LKS =~ ^[nN]$ ]]; then /usr/bin/lk setup || echo " aviso: a instalação do painel falhou (tente de novo: sudo lk setup)"; fi fi echo echo "${G}=============================================${N}" echo "${G} LK $(/usr/bin/lk version | awk '{print $2}') instalado com sucesso!${N}" echo "${G}=============================================${N}" echo " Abrir o menu: ${C}sudo lk${N}" echo " Painel web: ${C}sudo lk${N} -> opção 33" echo " SSH: ${C}sudo lk${N} -> opção 34" echo " Ligar/desligar site: ${C}lk site off meusite.com${N} / ${C}lk site on meusite.com${N}" echo " Modo servidor: ${C}sudo lk${N} -> opção 35 (apagar tela / voltar ao normal)" echo " Comandos diretos: ${C}lk help${N}" echo " Desinstalar: ${C}sudo bash $0 uninstall${N}" echo