#!/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