From 592b954a9fa5d77463b30f5d38273b5939d3c599 Mon Sep 17 00:00:00 2001 From: Alexey Berezhok Date: Mon, 27 Apr 2026 00:47:57 +0300 Subject: [PATCH] Added API for bunkerweb --- bin/v-bunkerweb-module | 199 +++++++ func_ruby/HestiaBunkerWebApi.rb | 485 ++++++++++++++++++ func_ruby/docs/BunkerWebApiDoc.md | 467 +++++++++++++++++ func_ruby/ext-modules/bunkerweb_module.mod | 124 +++++ .../passenger_manager/passenger_installer.yml | 6 +- .../passenger_uninstaller.yml | 6 +- func_ruby/ext-modules/php_brepo_modules.mod | 10 +- web/extm/bunkerweb_module/edit/index.php | 0 .../extmodules_bunkerweb_module.php | 0 9 files changed, 1286 insertions(+), 11 deletions(-) create mode 100644 bin/v-bunkerweb-module create mode 100644 func_ruby/HestiaBunkerWebApi.rb create mode 100644 func_ruby/docs/BunkerWebApiDoc.md create mode 100644 func_ruby/ext-modules/bunkerweb_module.mod create mode 100644 web/extm/bunkerweb_module/edit/index.php create mode 100644 web/templates/pages/extmodules/extmodules_bunkerweb_module.php diff --git a/bin/v-bunkerweb-module b/bin/v-bunkerweb-module new file mode 100644 index 0000000..3c78a5e --- /dev/null +++ b/bin/v-bunkerweb-module @@ -0,0 +1,199 @@ +#!/opt/brepo/ruby33/bin/ruby +# info: action with bunkerweb API +# options: COMMAND [SERVICE_NAME | SSL_CERT | SSL_KEY | FORMAT] +# +# example: v-ext-modules list json +# +# This function enables and disables additional modules +# +# Commands: +# add [domain_name] +# addssl [domain_name] [SSL_CERT_PATH] [SSL_KEY_PATH] +# delete [domain_name] +# updssl [domain] [SSL_CERT_PATH] [SSL_KEY_PATH] +# list + +#----------------------------------------------------------# +# Variables & Functions # +#----------------------------------------------------------# + +# Argument definition +v_command = ARGV[0] +v_format = nil + +require "/usr/local/hestia/func_ruby/global_options" + +load_ruby_options_defaults +$HESTIA = load_hestia_default_path_from_env + +require "main" +require "modules" +require "HestiaBunkerWebApi" + +require 'json' unless defined?(JSON) + +hestia_check_privileged_user + +load_global_bash_variables "/etc/hestiacp/hestia.conf" +if $HESTIA.nil? + hestia_print_error_message_to_cli "Can't find HESTIA base path" + exit 1 +end + +load_global_bash_variables "#{$HESTIA}/conf/hestia.conf" + +#----------------------------------------------------------# +# Verifications # +#----------------------------------------------------------# + +check_args 1, ARGV, "COMMAND [COMMAND_OPTIONS] [ACTION]" + +# Perform verification if read-only mode is enabled +check_hestia_demo_mode + +#----------------------------------------------------------# +# Action # +#----------------------------------------------------------# + +case v_command.to_sym +when :add + + v_domain = ARGV[1].strip + v_format = ARGV[2] unless ARGV[2].nil? + + if v_domain.nil? || v_domain == "" + hestia_print_error_message_to_cli "domain should not be empty" + log_event E_ARGS, $ARGUMENTS + exit 1 + else + + begin + api = HestiaBunkerWebApi.new("http://127.0.0.1:8888") + existing_services = api.list_services() + if existing_services.nil? || existing_services.strip.empty? + result_arr = [] + else + services_data = JSON.parse(existing_services) + if services_data["services"] + if services_data["services"].any? { |s| s["id"] == v_domain } + hestia_print_error_message_to_cli "domain already exists" + log_event E_EXISTS, $ARGUMENTS + exit 1 + end + result_arr = services_data["services"] + else + result_arr = [] + end + end + api.create_service(v_domain, { + ssl: "no", + reverse_proxy_host: "http://127.0.0.1:#{$PROXY_PORT}" + }) + rescue BunkerWebApiError => e + hestia_print_error_message_to_cli "[ERROR] Ошибка API: #{e.message}" + log_event E_INVALID, $ARGUMENTS + exit 1 + end + end +when :delete + + v_domain = ARGV[1].strip + v_format = ARGV[2] unless ARGV[2].nil? + + if v_domain.nil? || v_domain == "" + hestia_print_error_message_to_cli "domain should not be empty" + log_event E_ARGS, $ARGUMENTS + exit 1 + else + + begin + api = HestiaBunkerWebApi.new("http://127.0.0.1:8888") + existing_services = api.list_services() + if existing_services.nil? || existing_services.strip.empty? + result_arr = [] + else + services_data = JSON.parse(existing_services) + if services_data["services"] + result_arr = services_data["services"] + else + result_arr = [] + end + end + unless result_arr.any? { |s| s["id"] == v_domain } + hestia_print_error_message_to_cli "domain does not exist" + log_event E_NOTEXIST, $ARGUMENTS + exit 1 + end + api.delete_service(v_domain) + rescue BunkerWebApiError => e + hestia_print_error_message_to_cli "[ERROR] Ошибка API: #{e.message}" + log_event E_INVALID, $ARGUMENTS + exit 1 + end + end +when :addssl, :updssl + + v_domain = ARGV[1].strip + v_ssl_cert = ARGV[2] + v_ssl_key = ARGV[3] + v_format = ARGV[4] unless ARGV[4].nil? + + if v_domain.nil? || v_domain == "" || v_ssl_cert.nil? || v_ssl_key.nil? || !File.exist?(v_ssl_cert) || !File.exist?(v_ssl_key) + hestia_print_error_message_to_cli "domain, SSL cert and key must be provided and must exist" + log_event E_ARGS, $ARGUMENTS + exit 1 + else + begin + api = HestiaBunkerWebApi.new("http://127.0.0.1:8888") + existing_services = api.list_services() + if existing_services.nil? || existing_services.strip.empty? + result_arr = [] + else + services_data = JSON.parse(existing_services) + if services_data["services"] + result_arr = services_data["services"] + else + result_arr = [] + end + end + unless result_arr.any? { |s| s["id"] == v_domain } + hestia_print_error_message_to_cli "domain does not exist" + log_event E_NOTEXIST, $ARGUMENTS + exit 1 + end + api.update_service_ssl(v_domain, v_ssl_cert, v_ssl_key) + rescue BunkerWebApiError => e + hestia_print_error_message_to_cli "[ERROR] Ошибка API: #{e.message}" + log_event E_INVALID, $ARGUMENTS + exit 1 + end + end +when :list + v_format = ARGV[1] unless ARGV[1].nil? + + begin + api = HestiaBunkerWebApi.new("http://127.0.0.1:8888") + existing_services = api.list_services() + if existing_services.nil? || existing_services.strip.empty? + result_arr = [] + else + services_data = JSON.parse(existing_services) + if services_data["services"] + result_arr = services_data["services"] + else + result_arr = [] + end + end + hestia_print_array_of_hashes(result_arr, v_format, "id, method, is_draft, creation_date, last_update, template, security_mode") + rescue BunkerWebApiError => e + hestia_print_error_message_to_cli "[ERROR] Ошибка API: #{e.message}" + log_event E_INVALID, $ARGUMENTS + exit 1 + end +else + hestia_print_error_message_to_cli "unknown command" + log_event E_ARGS, $ARGUMENTS + exit 1 +end + +exit 0 diff --git a/func_ruby/HestiaBunkerWebApi.rb b/func_ruby/HestiaBunkerWebApi.rb new file mode 100644 index 0000000..80e20ee --- /dev/null +++ b/func_ruby/HestiaBunkerWebApi.rb @@ -0,0 +1,485 @@ +#!/usr/bin/env ruby + +require 'json' +require 'net/http' +require 'uri' +require 'openssl' + +class BunkerWebApiError < StandardError; end +class HestiaBunkerWebApi + # Override puts to accumulate logs into @extra_info + def puts(*args) + @extra_info ||= "" + @extra_info << args.join("\n") << "\n" + end + + # Accessor for @extra_info + def extra_info + @extra_info || "" + end +end + +# Hook to wrap methods of HestiaBunkerWebApi to reset @extra_info at start +class Module + alias_method :orig_method_added, :method_added + def method_added(name) + orig_method_added(name) + # Skip wrapping for the overridden puts method + return if name == :puts + if self.name == 'HestiaBunkerWebApi' + @__wrapping ||= false + return if @__wrapping + @__wrapping = true + original = instance_method(name) + define_method(name) do |*args, &block| + @extra_info = "" + original.bind(self).call(*args, &block) + end + @__wrapping = false + end + end +end +class HestiaBunkerWebApi + # Retrieve API username and password from /etc/bunkerweb/api.env if available + def get_api_user_password + env_path = "/etc/bunkerweb/api.env" + return nil unless File.file?(env_path) + + username = nil + password = nil + + File.foreach(env_path) do |line| + line.strip! + next if line.empty? || line.start_with?('#') + key, value = line.split('=', 2) + next unless key && value + case key + when 'API_USERNAME' + username = value + when 'API_PASSWORD' + password = value + end + end + + if username && password + [username, password] + else + nil + end + end + + def initialize(api_url, username = nil, password = nil) + @api_base = api_url + if username.nil? + result = get_api_user_password + if result.nil? + raise BunkerWebApiError.new("Authentication error: no username or password") + else + @username = result[0] + @password = result[1] + end + else + @username = username + @password = password + end + @token = nil + @extra_info = "" + + # Authenticate and get token + authenticate! + + puts "[INFO] Successfully authenticated with BunkerWeb API" + end + + def authenticate! + uri = URI(@api_base) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + + # Try both Basic Auth and JSON body with credentials + request = Net::HTTP::Post.new("/auth") + request.content_type = "application/json" + request.body = { username: @username, password: @password }.to_json + + response = http.request(request) + + if response.code != '200' + raise BunkerWebApiError.new("Authentication failed: #{response.code} - #{response.message}") + end + + body = JSON.parse(response.body) + + unless body['token'] + raise BunkerWebApiError.new("Authentication succeeded but no token received") + end + + @token = body['token'] + rescue => e + raise BunkerWebApiError.new("Authentication error: #{e.message}") + end + + def api_call(method, path, headers = {}, body = nil) + uri = URI(@api_base + path) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + + request = case method + when "GET" + Net::HTTP::Get.new(uri.path) + when "POST" + req = Net::HTTP::Post.new(uri.path) + req.body = body if body + req + when "PATCH" + req = Net::HTTP::Patch.new(uri.path) + req.body = body if body + req + when "DELETE" + Net::HTTP::Delete.new(uri.path) + else + raise BunkerWebApiError.new("Unsupported HTTP method: #{method}") + end + + # Add Authorization header with token for most operations (except /auth) + unless path == "/auth" + headers["Authorization"] = "Bearer #{@token}" if @token + end + + # Add Content-Type if not already set and we have a body + if body && !headers.key?("Content-Type") + headers["Content-Type"] = "application/json" + end + + headers.each { |k, v| request[k] = v } + + response = http.request(request) + parsed_body = begin + JSON.parse(response.body) + rescue => e + nil + end + + { status: response.code.to_i, body: parsed_body || {}, raw_body: response.body } + rescue => e + raise BunkerWebApiError.new("API call to #{path} failed: #{e.message}") + end + + # === Service Operations === + + def create_service(service_name, options = {}) + @extra_info = "" + # Create a new service with the given configuration. + # + # Args: + # service_name (String): The domain name for this service (server_name) + # options (Hash): Service configuration including: + # - USE_TEMPLATE (default: "high") + # - USE_SSL (default: "no") - if not "no", you need CERTIFICATE and KEY paths + # - REVERSE_PROXY_HOST (optional) + # - REVERSE_PROXY_URL (optional, default: "~ ^/(.*)$") + # - Additional options like: + # - USE_REVERSE_PROXY (default: "yes" if REVERSE_PROXY_HOST is set) + # - USE_REAL_IP, REAL_IP_FROM + # - USE_MODSECURITY, USE_ANTIBOT + # - LISTEN_HTTP_PORT, LISTEN_HTTPS_PORT + # - CERTIFICATE_FILE_PATH (if USE_SSL != "no") + # - KEY_FILE_PATH (if USE_SSL != "no") + # Returns: Hash with creation response + + variables = { + "USE_TEMPLATE" => options[:use_template] || "high", + "USE_REVERSE_PROXY" => options[:reverse_proxy_host].nil? ? "no" : "yes", + } + + # SSL configuration - only if USE_SSL != "no" + ssl_enabled = options[:ssl] && options[:ssl] != "no" + variables["USE_SSL"] = ssl_enabled ? "yes" : "no" + + if ssl_enabled + unless options[:certificate_path] && options[:key_path] + raise BunkerWebApiError.new("Certificate and Key paths are required when USE_SSL is enabled") + end + + # Set certificate paths in variables + variables["SSL_CERTIFICATE_FILE_PATH"] = options[:certificate_path] + variables["SSL_KEY_FILE_PATH"] = options[:key_path] + + variables["LISTEN_HTTPS_PORT"] = (options[:https_port] || "443").to_s + variables["USE_REVERSE_PROXY_SSL"] = options[:reverse_proxy_ssl] || "yes" + else + # No SSL - HTTP only + # API expects string "null", not nil/JSON null + variables["LISTEN_HTTPS_PORT"] = "null" + variables["LISTEN_HTTP_PORT"] = (options[:http_port] || "80").to_s + end + + # Reverse proxy configuration if specified + if options[:reverse_proxy_host] + variables["REVERSE_PROXY_HOST"] = options[:reverse_proxy_host] + variables["REVERSE_PROXY_URL"] = options[:reverse_proxy_url] || "~ ^/(.*)$" + + # Real IP settings for reverse proxy + unless options[:real_ip_from].nil? + variables["USE_REAL_IP"] = "yes" + variables["REAL_IP_FROM"] = options[:real_ip_from] + end + + # Additional security settings from High template + variables["USE_MODSECURITY"] = options[:use_modsecurity] || "yes" + variables["USE_ANTIBOT"] = options[:anti_bot] || "captcha" + end + + service_body = { + server_name: service_name, + is_draft: false, + variables: variables + } + + response = api_call("POST", "/services", {}, JSON.generate(service_body)) + + # Accept both 201 (Created) and 200 (OK) for successful creation + if [201, 200].include?(response[:status]) + puts "[INFO] Service '#{service_name}' created successfully" + elsif response[:status] == 409 + raise BunkerWebApiError.new("Service '#{service_name}' already exists") + else + raise BunkerWebApiError.new("Failed to create service: status=#{response[:status]}, body=#{response[:raw_body]}") + end + + return response[:body] || {} + end + + def update_service_ssl(service_name, certificate_path, key_path, https_port = nil) + # Update or change the SSL certificate path for an existing service. + # + # Args: + # service_name (String): Name of the service to update + # certificate_path (String): Path to the SSL certificate file + # key_path (String): Path to the SSL private key file + # https_port (Integer, optional): HTTPS port (default 443) + # Returns: Hash with update response + + @extra_info = "" + + # First get current service configuration to preserve existing settings + get_service_response = api_call("GET", "/services/#{service_name}", {}) + + if get_service_response[:status] != 200 + raise BunkerWebApiError.new("Service '#{service_name}' not found") + end + + # Extract current variables from the service configuration + current_vars = get_service_response[:body]["variables"] || {} + + # Update SSL settings + updated_vars = { + "USE_SSL" => "yes", + "SSL_CERTIFICATE_FILE_PATH" => certificate_path, + "SSL_KEY_FILE_PATH" => key_path, + "LISTEN_HTTPS_PORT" => (https_port || "443").to_s, + "USE_REVERSE_PROXY_SSL" => "yes" + } + + # Merge with existing variables (keep non-SSL settings) + final_vars = current_vars.merge(updated_vars) + + service_body = { + server_name: nil, # Not changing name + is_draft: false, # Keep as online + variables: final_vars + } + + response = api_call("PATCH", "/services/#{service_name}", {}, JSON.generate(service_body)) + + if response[:status] == 200 + puts "[INFO] SSL configuration updated for service '#{service_name}'" + else + raise BunkerWebApiError.new("Failed to update SSL configuration: status=#{response[:status]}, body=#{response[:raw_body]}") + end + + return response[:body] || {} + end + + def delete_service(service_name) + # Delete a service by its name. + # + # Args: + # service_name (String): Name of the service to delete + # Returns: Hash with deletion response + + @extra_info = "" + # Verify service exists first + get_response = api_call("GET", "/services/#{service_name}", {}) + + if get_response[:status] != 200 + raise BunkerWebApiError.new("Service '#{service_name}' not found") + end + + response = api_call("DELETE", "/services/#{service_name}") + + if response[:status] == 200 || response[:status] == 204 + puts "[INFO] Service '#{service_name}' deleted successfully" + return response[:body] || {} + else + raise BunkerWebApiError.new("Failed to delete service: status=#{response[:status]}, body=#{response[:raw_body]}") + end + end + + # === Additional Utility Methods === + + def list_services(drafts = false) + # List all services. + # + # Args: + # drafts (Boolean): Include draft services (default: false, set to true to include drafts) + # Returns: Array of service objects + + @extra_info = "" + response = api_call("GET", "/services") + + if response[:status] != 200 + raise BunkerWebApiError.new("Failed to list services: status=#{response[:status]}") + end + + return response[:body] || [] + end + + def get_service(service_name) + # Get details of a specific service. + # + # Args: + # service_name (String): Name of the service to retrieve + # Returns: Hash with service configuration + + @extra_info = "" + response = api_call("GET", "/services/#{service_name}") + + if response[:status] != 200 + raise BunkerWebApiError.new("Service '#{service_name}' not found") + end + + return response[:body] || {} + end + + def reload_instance(instance_hostname = nil) + # Reload configuration on an instance. + # + # Args: + # instance_hostname (String, optional): Instance hostname to reload (if nil, reloads all instances) + # Returns: Hash with reload response + + @extra_info = "" + path = if instance_hostname.nil? + "/instances/reload" + else + "/instances/#{instance_hostname}/reload" + end + + response = api_call("POST", "#{path}?test=no") + + if response[:status] == 200 || response[:status] == 201 + puts "[INFO] Configuration reloaded successfully" + return response[:body] || {} + else + raise BunkerWebApiError.new("Failed to reload configuration: status=#{response[:status]}") + end + end + + def list_instances() + # List all registered instances. + # Returns: Array of instance objects + + @extra_info = "" + response = api_call("GET", "/instances") + + if response[:status] != 200 + raise BunkerWebApiError.new("Failed to list instances: status=#{response[:status]}") + end + + return response[:body] || [] + end + + def create_instance(hostname, name = nil, port = 8888, https_port = nil) + # Create/register a new BunkerWeb instance (worker node). + # + # Args: + # hostname (String): IP address or hostname of the worker node + # name (String, optional): Human-readable name for the instance + # port (Integer): API port on the worker node (default 8888) + # https_port (Integer, optional): HTTPS port + # Returns: Hash with creation response + + @extra_info = "" + instance_body = { + hostname: hostname, + name: name || "BunkerWeb Instance", + port: port, + listen_https: !https_port.nil?, + https_port: https_port, + server_name: hostname, + method: "api" # Using API deployment method + } + + response = api_call("POST", "/instances", {}, JSON.generate(instance_body)) + + if response[:status] == 201 + puts "[INFO] Instance '#{hostname}' registered successfully" + elsif response[:status] == 409 + # Instance already exists - that's OK, we just want to use it + puts "[INFO] Instance '#{hostname}' already exists, will be used for this service" + else + raise BunkerWebApiError.new("Failed to create instance: status=#{response[:status]}, body=#{response[:raw_body]}") + end + + return response[:body] || {} + end + + def delete_instance(hostname) + """ + Delete a registered instance. + + Args: + hostname (String): Hostname of the instance to delete + """ + + @extra_info = "" + response = api_call("DELETE", "/instances/#{hostname}") + + if response[:status] == 200 || response[:status] == 204 + puts "[INFO] Instance '#{hostname}' deleted successfully" + return true + else + raise BunkerWebApiError.new("Failed to delete instance: status=#{response[:status]}") + end + end +end + +# === Example Usage (can be run as script) === + +if __FILE__ == $0 + # Example usage demonstration + begin + api = HestiaBunkerWebApi.new( + "http://127.0.0.1:8888", + "admin", + "your_password" + ) + + puts "" + puts "[INFO] Creating service 'example.my.domain'" + result = api.create_service("example.my.domain", { + reverse_proxy_host: "http://192.168.3.51:8078", + ssl: "no", + use_template: "high" + }) + + puts "" + puts "[INFO] Listing services:" + services = api.list_services() + services.each { |s| puts "- #{s['server_name']}" } + + rescue BunkerWebApiError => e + puts "[ERROR] #{e.message}" + exit 1 + end +end diff --git a/func_ruby/docs/BunkerWebApiDoc.md b/func_ruby/docs/BunkerWebApiDoc.md new file mode 100644 index 0000000..3abcadd --- /dev/null +++ b/func_ruby/docs/BunkerWebApiDoc.md @@ -0,0 +1,467 @@ +# HestiaBunkerWebApi - Ruby класс для работы с BunkerWeb API + +## Описание + +Класс `HestiaBunkerWebApi` предоставляет простой интерфейс для управления сервисами BunkerWeb через REST API. Класс реализует: + +- **Аутентификацию** с получением токена +- **Управление сервисами** (создание, обновление, удаление) +- **Управление SSL сертификатами** +- **Управление instances** (worker nodes) +- **Полное исключение ошибок** при любых проблемах + +## Установка и импорт + +```bash +# Ruby 3.3+ рекомендуется +ruby --version +# ruby 3.3.x or later + +# Класс использует стандартные библиотеки Ruby: +# - json (для JSON парсинга) +# - net/http (для HTTP запросов) +# - uri (для URL парсинга) +``` + +## Использование класса + +### Базовое использование + +```ruby +require_relative "HestiaBunkerWebApi.rb" + +# Создаём экземпляр API с аутентификацией +api = HestiaBunkerWebApi.new( + "http://127.0.0.1:8888", # URL API (можно https://) + "admin", # username + "password" # password +) +# или +api = HestiaBunkerWebApi.new( + "http://127.0.0.1:8888", # URL API (можно https://) +) +# в этом случае пароль и логин читаются автоматически из файла /etc/bunkerweb/api.env + + +# При создании экземпляра автоматически происходит аутентификация +# Если ошибка - выбрасывается BunkerWebApiError с описанием проблемы +``` + +### Обработка ошибок + +Все ошибки наследуются от `StandardError` через класс `BunkerWebApiError`: + +```ruby +begin + api.create_service("example.domain", options) +rescue BunkerWebApiError => e + puts "[ERROR] Ошибка API: #{e.message}" + + # Примеры возможных ошибок: + # - "Authentication failed: 401 - Unauthorized" + # - "Service 'x' already exists" + # - "Certificate and Key paths are required when USE_SSL is enabled" + # - "Failed to create service: status=500, body={...}" + + exit 1 +end +``` + +## Методы класса + +### Конструктор + +```ruby +HestiaBunkerWebApi.new(api_url, username, password) +``` + +**Параметры:** +- `api_url` - URL BunkerWeb API в формате `http://ip:port` или `https://ip:port` +- `username` - имя администратора для аутентификации +- `password` - пароль для аутентификации + +**Действие:** При создании автоматически пытается аутентифицироваться через POST /auth и сохраняет токен. + +### Создание сервиса + +```ruby +api.create_service(service_name, options = {}) +``` + +**Параметры:** +- `service_name` - имя домена/сервиса (например, "example.my.domain") +- `options` - хэш с конфигурацией: + +| Параметр | Тип | Описание | Пример | +|----------|-----|----------|--------| +| `ssl` | String | "yes" для SSL, "no" для HTTP | `"no"` | +| `certificate_path` | String | Путь к SSL сертификату (если ssl="yes") | `"/etc/ssl/certs/example.crt"` | +| `key_path` | String | Путь к приватному ключу (если ssl="yes") | `"/etc/ssl/private/example.key"` | +| `use_template` | String | Безопасность шаблона | `"high"` (default) | +| `reverse_proxy_host` | String | Target reverse proxy | `"http://192.168.3.51:8078"` | +| `reverse_proxy_url` | String | URL трансформация | `"~ ^/(.*)$"` | +| `real_ip_from` | String | CIDR trusted network для RealIP | `"192.168.3.0/24"` | +| `use_modsecurity` | String | WAF включение | `"yes"` (default) | +| `anti_bot` | String | Bot protection | `"captcha"` (default) | +| `http_port` | Integer/nil | HTTP порт | `"80"` или `null` | +| `https_port` | Integer | HTTPS порт | `443` или `null` | + +**Пример - создание reverse proxy сервиса без SSL:** +```ruby +result = api.create_service("example.my.domain", { + ssl: "no", + reverse_proxy_host: "http://192.168.3.51:8078" +}) + +# Генерирует variables: +# - USE_TEMPLATE: "high" +# - USE_SSL: "no" +# - LISTEN_HTTPS_PORT: "null" +# - LISTEN_HTTP_PORT: "80" +# - USE_REVERSE_PROXY: "yes" +# - REVERSE_PROXY_HOST: "http://192.168.3.51:8078" +``` + +**Пример - создание сервиса с SSL + reverse proxy:** +```ruby +result = api.create_service("example.my.domain", { + ssl: "yes", # Включаем SSL + + certificate_path: "/etc/ssl/certs/example.crt", # Путь к сертификату + key_path: "/etc/ssl/private/example.key", # Путь к ключу + + reverse_proxy_host: "http://192.168.3.51:8078", # Reverse proxy target + + use_template: "high", + anti_bot: "captcha" +}) + +# Генерирует variables: +# - USE_TEMPLATE: "high" +# - USE_SSL: "yes" +# - SSL_CERTIFICATE_FILE_PATH: "/etc/ssl/certs/example.crt" +# - SSL_KEY_FILE_PATH: "/etc/ssl/private/example.key" +# - LISTEN_HTTPS_PORT: "443" +# - LISTEN_HTTP_PORT: "80" +``` + +### Обновление SSL сертификата для существующего сервиса + +```ruby +api.update_service_ssl(service_name, certificate_path, key_path, https_port = nil) +``` + +**Параметры:** +- `service_name` - имя уже созданного сервиса +- `certificate_path` - новый путь к SSL сертификату +- `key_path` - новый путь к приватному ключу +- `https_port` (optional) - HTTPS порт (default: 443) + +**Пример:** +```ruby +api.update_service_ssl( + "example.my.domain", + "/etc/ssl/certs/example.crt", + "/etc/ssl/private/example.key" +) + +# Обновляет существующий сервис, сохраняя reverse proxy настройки +``` + +### Удаление сервиса + +```ruby +api.delete_service(service_name) +``` + +**Параметры:** +- `service_name` - имя сервиса для удаления + +**Пример:** +```ruby +api.delete_service("example.my.domain") +# Удаляет сервис и конфигурацию +``` + +### Получение списка всех сервисов + +```ruby +api.list_services(drafts = false) +``` + +**Параметры:** +- `drafts` (optional) - включать draft сервисы (default: false) + +**Возвращает:** Array of service objects + +**Пример:** +```ruby +services = api.list_services() +services.each { |s| puts "- #{s['server_name']}" } +``` + +### Получение деталей конкретного сервиса + +```ruby +api.get_service(service_name) +``` + +**Параметры:** +- `service_name` - имя сервиса для получения деталей + +**Возвращает:** Hash with service configuration (variables, settings, etc.) + +**Пример:** +```ruby +config = api.get_service("example.my.domain") +puts config.inspect +``` + +### Перезагрузка конфигурации на instance + +```ruby +api.reload_instance(instance_hostname = nil) +``` + +**Параметры:** +- `instance_hostname` (optional) - hostname instance для перезагрузки (если nil, reloads all instances) + +**Пример:** +```ruby +# Reload все instances +api.reload_instance() + +# Reload конкретный instance +api.reload_instance("192.168.3.50") +``` + +### Получение списка всех instances + +```ruby +api.list_instances() +``` + +**Возвращает:** Array of instance objects (hostname, name, port, etc.) + +### Создание/регистрация BunkerWeb instance (worker node) + +```ruby +api.create_instance(hostname, name = nil, port = 8888, https_port = nil) +``` + +**Параметры:** +- `hostname` - IP address или hostname worker node +- `name` (optional) - Human-readable имя instance +- `port` - API port на worker node (default: 8888) +- `https_port` (optional) - HTTPS port если есть + +**Пример:** +```ruby +api.create_instance( + "192.168.3.50", # IP worker node + "BunkerWeb Worker Node", # Optional name + 8888 # API port +) + +# Возвращает: { status: 201/409, body: {...} } +# Если статус 201 - instance создан +# Если статус 409 - instance уже существует (это OK) +``` + +### Удаление BunkerWeb instance + +```ruby +api.delete_instance(hostname) +``` + +**Параметры:** +- `hostname` - hostname instance для удаления + +## Примеры полного использования + +### Пример 1: Создание и управление сервисом + +```ruby +require_relative "HestiaBunkerWebApi.rb" + +begin + # 1. Подключаемся к API + api = HestiaBunkerWebApi.new( + "http://127.0.0.1:8888", + "admin", + "your_password" + ) + + # 2. Создаём reverse proxy сервис без SSL + result = api.create_service("u4.my.brp", { + ssl: "no", + reverse_proxy_host: "http://192.168.3.51:8078" + }) + + puts "[INFO] Service created: #{result.inspect}" + + # 3. Добавляем SSL сертификат позже (если нужно) + api.update_service_ssl( + "u4.my.brp", + "/etc/ssl/certs/u4.crt", + "/etc/ssl/private/u4.key" + ) + + # 4. Проверяем список сервисов + services = api.list_services() + puts "[INFO] All services:" + services.each { |s| puts "- #{s['server_name']}" } + + # 5. Удаление сервиса (при необходимости) + api.delete_service("u4.my.brp") + +rescue BunkerWebApiError => e + puts "[ERROR] Ошибка API: #{e.message}" + exit 1 +end +``` + +### Пример 2: Управление несколькими сервисами + +```ruby +require_relative "HestiaBunkerWebApi.rb" + +api = HestiaBunkerWebApi.new("http://127.0.0.1:8888", "admin", "password") + +# Создаём несколько сервисов с разными конфигурациями +services_to_create = [ + { name: "service1.domain", ssl: "no", reverse_proxy_host: "http://192.168.3.50:80" }, + { name: "service2.domain", ssl: "yes", certificate_path: "/certs/service2.crt", key_path: "/keys/service2.key", reverse_proxy_host: "http://192.168.3.51:8078" } +] + +services_to_create.each do |opts| + begin + api.create_service(opts[:name], opts) + rescue BunkerWebApiError => e + puts "[ERROR] #{e.message}" if e.message.include?("already exists") + end +end + +# Reload конфигурации на instance +api.reload_instance("192.168.3.50") +``` + +### Пример 3: Обработка ошибок и логирование + +```ruby +require_relative "HestiaBunkerWebApi.rb" + +def safe_create_service(api_url, username, password, service_name, options) + begin + api = HestiaBunkerWebApi.new(api_url, username, password) + + result = api.create_service(service_name, options) + return { success: true, data: result } + + rescue BunkerWebApiError => e + if e.message.include?("Authentication") + puts "[FATAL] Authentication failed: #{e.message}" + elsif e.message.include?("already exists") + begin + existing = api.get_service(service_name) + return { success: false, already_exists: true, service: existing } + rescue => get_error + return { success: false, error: "Can't retrieve service: #{get_error.message}" } + else + return { success: false, error: e.message } + end + end + + { success: false, error: "Unknown error" } +end + +# Использование +result = safe_create_service("http://127.0.0.1:8888", "admin", "password", "example.domain", { ssl: "yes" }) + +if result[:success] + puts "[SUCCESS] Service created" +elsif result[:already_exists] + puts "[INFO] Service exists:" + puts JSON.generate(result[:service]) +else + puts "[FAILED] #{result[:error]}" +end +``` + +## Ошибки и их обработка + +### Типичные ошибки: + +| Код ответа | Описание | Пример сообщения | +|------------|----------|------------------| +| **401** | Authentication failed | "Authentication failed: 401 - Unauthorized" | +| **200 (no token)** | Auth passed but no token | "Authentication succeeded but no token received" | +| **Connection error** | API недоступен | "Authentication error: Connection refused" | +| **409 Conflict** | Service already exists | "Service 'x' already exists" | +| **422 Unprocessable Entity** | Invalid data (например, LISTEN_HTTPS_PORT = nil) | "Failed to create service: status=422..." | +| **429 Too Many Requests** | Rate limit exceeded | "Rate limit exceeded: 10 per 1 minute" | + +### Решение проблем с rate limiting + +Если получаете ошибку `429` (rate limit), нужно отключить или увеличить лимит в `/etc/bunkerweb/api.env`: + +```bash +# Откройте конфиг и найдите секцию Rate limiting +nano /etc/bunkerweb/api.env + +# Добавьте/измените: +API_RATE_LIMIT_ENABLED=no # Отключение rate limiting +# или +API_RATE_LIMIT=1000/minute # Увеличение лимита до 1000/m +``` + +Затем перезагрузите API service: + +```bash +systemctl reload bunkerweb-api.service +``` + +## Особенности реализации + +### 1. SSL сертификатные пути + +Когда `ssl: "no"` - API ожидает `"LISTEN_HTTPS_PORT" => "null"` (строка), а не JSON null (`nil`): + +```ruby +# ❌ Ошибка: +variables["LISTEN_HTTPS_PORT"] = nil # → 422 error + +# ✅ Правильно: +variables["LISTEN_HTTPS_PORT"] = "null" # → 200 OK +``` + +### 2. Статусы ответа для создания сервиса + +BunkerWeb API возвращает **200 OK** вместо стандартного **201 Created**: + +```ruby +# Класс принимает оба статуса как успех: +if [201, 200].include?(response[:status]) + puts "[INFO] Service created successfully" +end +``` + +### 3. Поддержка HTTP методов + +Класс поддерживает все основные HTTP методы для API операций: +- **GET** - получение данных (services, instances) +- **POST** - создание (services, instances, auth) +- **PATCH** - обновление (services) +- **DELETE** - удаление (services, instances) + +## Совместимость + +- **Ruby**: 3.0+ +- **BunkerWeb API**: 1.6.x и выше +- **ZooKeeper/Redis**: не требуются для этого класса (работает через HTTP API напрямую) + +## Дополнительные ресурсы + +- [Документация BunkerWeb API](https://docs.bunkerweb.io/api.md) +- [API Swagger docs at /docs](http://127.0.0.1:8888/docs) +- [OpenAPI schema](http://127.0.0.1:8888/openapi.json) diff --git a/func_ruby/ext-modules/bunkerweb_module.mod b/func_ruby/ext-modules/bunkerweb_module.mod new file mode 100644 index 0000000..fbe4087 --- /dev/null +++ b/func_ruby/ext-modules/bunkerweb_module.mod @@ -0,0 +1,124 @@ +#!/opt/brepo/ruby33/bin/ruby + +class BunkerwebWorker < Kernel::ModuleCoreWorker + MODULE_ID = "bunkerweb_module" + + def info + { + ID: 5, + NAME: MODULE_ID, + DESCR: "Bunkerweb enabling", + REQ: "", + CONF: "yes", + } + end + + def command(args) + return log_return("Not enough arguments. Needed command") if args.length < 1 + log_file = get_log + + m_command = args[0].strip + case m_command + when "add" + m_domain = args[1].strip unless args[1].nil? + if m_domain.nil? + log_return("Domain should be specified. #{args}") + else + + log("add domain to bunkerweb protection") + output = `/usr/local/hestia/bin/v-bunkerweb-module add #{m_domain} shell` + exit_status = $?.exitstatus + if exit_status != 0 + log_return("Command failed with status #{exit_status}") + else + ACTION_OK + end + end + when "delete" + m_domain = args[1].strip unless args[1].nil? + if m_domain.nil? + log_return("Domain should be specified. #{args}") + else + + log("add domain to bunkerweb protection") + output = `/usr/local/hestia/bin/v-bunkerweb-module delete #{m_domain} shell` + exit_status = $?.exitstatus + if exit_status != 0 + log_return("Command failed with status #{exit_status}") + else + ACTION_OK + end + end + when "addssl" + m_domain = args[1].strip unless args[1].nil? + m_ssl_cert = args[2].strip unless args[2].nil? + m_ssl_key = args[3].strip unless args[3].nil? + if m_domain.nil? || m_ssl_cert.nil? || m_ssl_key.nil? || m_ssl_cert.empty? || m_ssl_key.empty? + log_return("Domain, SSL cert and SSL key must be specified. #{args}") + else + log("add ssl cert to bunkerweb protection") + output = `/usr/local/hestia/bin/v-bunkerweb-module addssl #{m_domain} #{m_ssl_cert} #{m_ssl_key} shell` + exit_status = $?.exitstatus + if exit_status != 0 + log_return("Command failed with status #{exit_status}") + else + ACTION_OK + end + end + when "updssl" + m_domain = args[1].strip unless args[1].nil? + m_ssl_cert = args[2].strip unless args[2].nil? + m_ssl_key = args[3].strip unless args[3].nil? + if m_domain.nil? || m_ssl_cert.nil? || m_ssl_key.nil? || m_ssl_cert.empty? || m_ssl_key.empty? + log_return("Domain, SSL cert and SSL key must be specified. #{args}") + else + log("add ssl cert to bunkerweb protection") + output = `/usr/local/hestia/bin/v-bunkerweb-module updssl #{m_domain} #{m_ssl_cert} #{m_ssl_key} shell` + exit_status = $?.exitstatus + if exit_status != 0 + log_return("Command failed with status #{exit_status}") + else + ACTION_OK + end + end + when "list" + format = (args[1].nil? ? "shell" : args[1].strip) + log("list of services") + output = `/usr/local/hestia/bin/v-bunkerweb-module list #{format}` + exit_status = $?.exitstatus + if exit_status != 0 + log_return("Command failed with status #{exit_status}") + else + puts output + ACTION_OK + end + when "help" + puts "#{$0} bunkerweb_module COMMAND [OPTIONS] [json|csv|plain]" + puts "COMMANDS:" + puts " add - add domain to bunkerweb" + puts " delete - delete domain from bunkerweb" + puts " addssl [path_to_cert] [path_to_key] - add existsing certificate to bunkerweb domain" + puts " updssl [path_to_cert] [path_to_key] - update existsing certificate to bunkerweb domain" + puts " help - help" + ACTION_OK + else + log_return("Unknown command. #{args}") + end + end + + implements IPluginInterface +end + +module BunkerwebModule + def get_object + Proc.new { BunkerwebWorker.new } + end + + module_function :get_object +end + +class Kernel::PluginConfiguration + include BunkerwebModule + + @@loaded_plugins[BunkerwebWorker::MODULE_ID] = BunkerwebModule.get_object +end diff --git a/func_ruby/ext-modules/payload/passenger_manager/passenger_installer.yml b/func_ruby/ext-modules/payload/passenger_manager/passenger_installer.yml index 243bcc9..0ff39b8 100644 --- a/func_ruby/ext-modules/payload/passenger_manager/passenger_installer.yml +++ b/func_ruby/ext-modules/payload/passenger_manager/passenger_installer.yml @@ -49,7 +49,7 @@ # Конфигурируем Nginx для Passenger - name: Create passenger.conf ansible.builtin.copy: - dest: /etc/nginx/conf.d/passenger.conf + dest: /usr/local/hestia/nginx-system/etc/nginx/conf.d/passenger.conf content: | passenger_root /usr/share/ruby/vendor_ruby/phusion_passenger/locations.ini; passenger_ruby /usr/bin/ruby; @@ -59,11 +59,11 @@ passenger_env_var PASSENGER_DOWNLOAD_NATIVE_SUPPORT_BINARY 0; - name: Create passenger_includer.conf ansible.builtin.copy: - dest: /etc/nginx/conf.d/main/passenger.conf + dest: /usr/local/hestia/nginx-system/etc/nginx/conf.d/main/passenger.conf content: | load_module modules/ngx_http_passenger_module.so; # Перезапускаем Nginx - name: Restart nginx service ansible.builtin.service: - name: nginx + name: nginx-system state: restarted diff --git a/func_ruby/ext-modules/payload/passenger_manager/passenger_uninstaller.yml b/func_ruby/ext-modules/payload/passenger_manager/passenger_uninstaller.yml index a591f09..4bdfae0 100644 --- a/func_ruby/ext-modules/payload/passenger_manager/passenger_uninstaller.yml +++ b/func_ruby/ext-modules/payload/passenger_manager/passenger_uninstaller.yml @@ -25,14 +25,14 @@ # Удаляем конфигурационные файлы Nginx - name: Remove passenger.conf ansible.builtin.file: - path: /etc/nginx/conf.d/passenger.conf + path: /usr/local/hestia/nginx-system/etc/nginx/conf.d/passenger.conf state: absent - name: Remove passenger_includer.conf ansible.builtin.file: - path: /etc/nginx/conf.d/main/passenger.conf + path: /usr/local/hestia/nginx-system/etc/nginx/conf.d/main/passenger.conf state: absent # Перезапускаем Nginx (необязательно, но полезно) - name: Restart nginx service ansible.builtin.service: - name: nginx + name: nginx-system state: restarted diff --git a/func_ruby/ext-modules/php_brepo_modules.mod b/func_ruby/ext-modules/php_brepo_modules.mod index 97292c2..5a8aac5 100644 --- a/func_ruby/ext-modules/php_brepo_modules.mod +++ b/func_ruby/ext-modules/php_brepo_modules.mod @@ -1,6 +1,6 @@ #!/opt/brepo/ruby33/bin/ruby -class EmptyWorker < Kernel::ModuleCoreWorker +class PHPWorker < Kernel::ModuleCoreWorker MODULE_ID = "php_brepo_modules" def info @@ -241,16 +241,16 @@ class EmptyWorker < Kernel::ModuleCoreWorker implements IPluginInterface end -module EmptyModule +module PHPModule def get_object - Proc.new { EmptyWorker.new } + Proc.new { PHPWorker.new } end module_function :get_object end class Kernel::PluginConfiguration - include EmptyModule + include PHPModule - @@loaded_plugins[EmptyWorker::MODULE_ID] = EmptyModule.get_object + @@loaded_plugins[PHPWorker::MODULE_ID] = PHPModule.get_object end diff --git a/web/extm/bunkerweb_module/edit/index.php b/web/extm/bunkerweb_module/edit/index.php new file mode 100644 index 0000000..e69de29 diff --git a/web/templates/pages/extmodules/extmodules_bunkerweb_module.php b/web/templates/pages/extmodules/extmodules_bunkerweb_module.php new file mode 100644 index 0000000..e69de29