opn_api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +25 -0
- data/README.md +979 -0
- data/bin/opn-api +7 -0
- data/lib/opn_api/cli/commands/api.rb +57 -0
- data/lib/opn_api/cli/commands/backup.rb +38 -0
- data/lib/opn_api/cli/commands/base.rb +50 -0
- data/lib/opn_api/cli/commands/device.rb +53 -0
- data/lib/opn_api/cli/commands/plugin.rb +67 -0
- data/lib/opn_api/cli/commands/reconfigure.rb +40 -0
- data/lib/opn_api/cli/commands/resource.rb +248 -0
- data/lib/opn_api/cli/formatter.rb +198 -0
- data/lib/opn_api/cli/main.rb +160 -0
- data/lib/opn_api/client.rb +204 -0
- data/lib/opn_api/config.rb +111 -0
- data/lib/opn_api/errors.rb +45 -0
- data/lib/opn_api/id_resolver.rb +222 -0
- data/lib/opn_api/logger.rb +29 -0
- data/lib/opn_api/normalize.rb +47 -0
- data/lib/opn_api/resource.rb +142 -0
- data/lib/opn_api/resource_registry.rb +377 -0
- data/lib/opn_api/service_reconfigure.rb +293 -0
- data/lib/opn_api/version.rb +5 -0
- data/lib/opn_api.rb +32 -0
- metadata +73 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module OpnApi
|
|
7
|
+
module CLI
|
|
8
|
+
# Output formatter for CLI results. Supports table, JSON, and YAML output.
|
|
9
|
+
#
|
|
10
|
+
# Table output supports field filtering:
|
|
11
|
+
# - Explicit fields via -F (e.g. -F uuid,name,type)
|
|
12
|
+
# - Default: first 5 fields (to keep output readable)
|
|
13
|
+
# - All fields via -A
|
|
14
|
+
# JSON and YAML always output all data unmodified.
|
|
15
|
+
module Formatter
|
|
16
|
+
# Default number of columns shown in table output when no -F or -A is given.
|
|
17
|
+
DEFAULT_MAX_FIELDS = 5
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Formats data according to the specified format.
|
|
22
|
+
#
|
|
23
|
+
# @param data [Object] Data to format (Hash, Array, String)
|
|
24
|
+
# @param format [Symbol] Output format (:table, :json, :yaml)
|
|
25
|
+
# @param fields [Array<String>, nil] Explicit field names for table output
|
|
26
|
+
# @param all_fields [Boolean] Show all fields in table output
|
|
27
|
+
# @param show_empty [Boolean] Show empty fields in table output (default: hidden)
|
|
28
|
+
# @return [String] Formatted output
|
|
29
|
+
def format(data, format: :table, fields: nil, all_fields: false, show_empty: false)
|
|
30
|
+
case format
|
|
31
|
+
when :json
|
|
32
|
+
format_json(data)
|
|
33
|
+
when :yaml
|
|
34
|
+
format_yaml(data)
|
|
35
|
+
else
|
|
36
|
+
format_table(data, fields: fields, all_fields: all_fields, show_empty: show_empty)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Pretty-printed JSON output (always full data).
|
|
41
|
+
def format_json(data)
|
|
42
|
+
JSON.pretty_generate(data)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# YAML output (always full data).
|
|
46
|
+
def format_yaml(data)
|
|
47
|
+
data.to_yaml
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Human-readable table output with optional field filtering.
|
|
51
|
+
def format_table(data, fields: nil, all_fields: false, show_empty: false)
|
|
52
|
+
case data
|
|
53
|
+
when Array
|
|
54
|
+
format_array_table(data, fields: fields, all_fields: all_fields)
|
|
55
|
+
when Hash
|
|
56
|
+
format_hash_table(data, show_empty: show_empty)
|
|
57
|
+
else
|
|
58
|
+
data.to_s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Formats an array of hashes as a column-aligned table.
|
|
63
|
+
# Applies field filtering for readability.
|
|
64
|
+
def format_array_table(rows, fields: nil, all_fields: false)
|
|
65
|
+
return '(no results)' if rows.empty?
|
|
66
|
+
|
|
67
|
+
# Flatten nested values to strings for display
|
|
68
|
+
flat_rows = rows.map do |row|
|
|
69
|
+
row.transform_values { |v| v.is_a?(Hash) || v.is_a?(Array) ? JSON.generate(v) : v.to_s }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Determine which columns to show
|
|
73
|
+
all_keys = flat_rows.flat_map(&:keys).uniq
|
|
74
|
+
display_keys = select_display_keys(all_keys, fields: fields, all_fields: all_fields)
|
|
75
|
+
|
|
76
|
+
# Calculate column widths
|
|
77
|
+
widths = display_keys.to_h do |k|
|
|
78
|
+
[k, ([k.length] + flat_rows.map { |r| r.fetch(k, '').length }).max]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Header line
|
|
82
|
+
header = display_keys.map { |k| k.ljust(widths[k]) }.join(' ')
|
|
83
|
+
separator = display_keys.map { |k| '-' * widths[k] }.join(' ')
|
|
84
|
+
|
|
85
|
+
# Data lines
|
|
86
|
+
lines = flat_rows.map do |row|
|
|
87
|
+
display_keys.map { |k| row.fetch(k, '').ljust(widths[k]) }.join(' ')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Add hint about hidden fields if any were truncated
|
|
91
|
+
result = [header, separator, *lines].join("\n")
|
|
92
|
+
hidden_count = all_keys.length - display_keys.length
|
|
93
|
+
if hidden_count.positive?
|
|
94
|
+
result += "\n\n(#{hidden_count} more field(s) hidden, use -A to show all or -F to select)"
|
|
95
|
+
end
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Formats a hash as key-value pairs with recursive indentation
|
|
100
|
+
# for nested structures.
|
|
101
|
+
#
|
|
102
|
+
# @param hash [Hash] Hash to format
|
|
103
|
+
# @param show_empty [Boolean] Include fields with empty string values
|
|
104
|
+
def format_hash_table(hash, show_empty: false)
|
|
105
|
+
return '(empty)' if hash.empty?
|
|
106
|
+
|
|
107
|
+
format_hash_recursive(hash, 0, show_empty: show_empty)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Recursively formats a hash with indentation for nested structures.
|
|
111
|
+
#
|
|
112
|
+
# @param hash [Hash] Hash to format
|
|
113
|
+
# @param indent [Integer] Current indentation level
|
|
114
|
+
# @param show_empty [Boolean] Include fields with empty string values
|
|
115
|
+
# @return [String] Formatted output
|
|
116
|
+
def format_hash_recursive(hash, indent, show_empty: false)
|
|
117
|
+
prefix = ' ' * indent
|
|
118
|
+
# Filter out empty string values unless show_empty is set
|
|
119
|
+
display_hash = show_empty ? hash : hash.reject { |_k, v| v.is_a?(String) && v.empty? }
|
|
120
|
+
return "#{prefix}(all fields empty)" if display_hash.empty?
|
|
121
|
+
|
|
122
|
+
max_key = display_hash.keys.map { |k| k.to_s.length }.max
|
|
123
|
+
|
|
124
|
+
display_hash.map do |k, v|
|
|
125
|
+
label = "#{prefix}#{k.to_s.ljust(max_key)}"
|
|
126
|
+
format_value_recursive(label, v, indent, show_empty: show_empty)
|
|
127
|
+
end.join("\n")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Formats a single value, recursing into hashes and arrays.
|
|
131
|
+
#
|
|
132
|
+
# @param label [String] Pre-formatted "key" label with padding
|
|
133
|
+
# @param value [Object] Value to format
|
|
134
|
+
# @param indent [Integer] Current indentation level
|
|
135
|
+
# @param show_empty [Boolean] Include fields with empty string values
|
|
136
|
+
# @return [String] Formatted line(s)
|
|
137
|
+
def format_value_recursive(label, value, indent, show_empty: false)
|
|
138
|
+
case value
|
|
139
|
+
when Hash
|
|
140
|
+
if value.empty?
|
|
141
|
+
"#{label} (empty)"
|
|
142
|
+
else
|
|
143
|
+
# Render nested hash on next lines with increased indent
|
|
144
|
+
"#{label}:\n#{format_hash_recursive(value, indent + 1, show_empty: show_empty)}"
|
|
145
|
+
end
|
|
146
|
+
when Array
|
|
147
|
+
if value.empty?
|
|
148
|
+
"#{label} (none)"
|
|
149
|
+
else
|
|
150
|
+
# Render array items on next lines with increased indent
|
|
151
|
+
items = value.map { |item| format_array_item(item, indent + 1, show_empty: show_empty) }
|
|
152
|
+
"#{label}:\n#{items.join("\n")}"
|
|
153
|
+
end
|
|
154
|
+
else
|
|
155
|
+
"#{label} #{value}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Formats a single array item with indentation.
|
|
160
|
+
#
|
|
161
|
+
# @param item [Object] Array element
|
|
162
|
+
# @param indent [Integer] Indentation level
|
|
163
|
+
# @param show_empty [Boolean] Include fields with empty string values
|
|
164
|
+
# @return [String] Formatted line(s)
|
|
165
|
+
def format_array_item(item, indent, show_empty: false)
|
|
166
|
+
prefix = ' ' * indent
|
|
167
|
+
case item
|
|
168
|
+
when Hash
|
|
169
|
+
# Render hash items with "- " prefix for first line
|
|
170
|
+
lines = format_hash_recursive(item, indent, show_empty: show_empty).split("\n")
|
|
171
|
+
first = lines.first&.sub(%r{\A#{Regexp.escape(prefix)}}, "#{prefix}- ")
|
|
172
|
+
rest = lines[1..]&.map { |l| " #{l}" }
|
|
173
|
+
[first, *rest].compact.join("\n")
|
|
174
|
+
else
|
|
175
|
+
"#{prefix}- #{item}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Determines which keys to display based on filtering options.
|
|
180
|
+
#
|
|
181
|
+
# @param all_keys [Array<String>] All available column names
|
|
182
|
+
# @param fields [Array<String>, nil] Explicit field selection
|
|
183
|
+
# @param all_fields [Boolean] Show all fields
|
|
184
|
+
# @return [Array<String>] Keys to display
|
|
185
|
+
def select_display_keys(all_keys, fields: nil, all_fields: false)
|
|
186
|
+
if fields
|
|
187
|
+
# Explicit field selection: only show requested fields that exist
|
|
188
|
+
fields.select { |f| all_keys.include?(f) }
|
|
189
|
+
elsif all_fields || all_keys.length <= DEFAULT_MAX_FIELDS
|
|
190
|
+
all_keys
|
|
191
|
+
else
|
|
192
|
+
# Default: show first N fields
|
|
193
|
+
all_keys.first(DEFAULT_MAX_FIELDS)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require_relative 'formatter'
|
|
5
|
+
require_relative 'commands/base'
|
|
6
|
+
require_relative 'commands/api'
|
|
7
|
+
require_relative 'commands/backup'
|
|
8
|
+
require_relative 'commands/device'
|
|
9
|
+
require_relative 'commands/plugin'
|
|
10
|
+
require_relative 'commands/reconfigure'
|
|
11
|
+
require_relative 'commands/resource'
|
|
12
|
+
|
|
13
|
+
module OpnApi
|
|
14
|
+
module CLI
|
|
15
|
+
# Main CLI dispatcher. Parses global options, extracts the subcommand,
|
|
16
|
+
# and delegates to the appropriate command module.
|
|
17
|
+
module Main
|
|
18
|
+
COMMANDS = {
|
|
19
|
+
'backup' => { handler: Commands::Backup.method(:download), desc: 'Download config backup' },
|
|
20
|
+
'create' => { handler: Commands::Resource.method(:create), desc: 'Create resource' },
|
|
21
|
+
'delete' => { handler: Commands::Resource.method(:delete), desc: 'Delete resource' },
|
|
22
|
+
'devices' => { handler: Commands::Device.method(:list), desc: 'List configured devices' },
|
|
23
|
+
'get' => { handler: Commands::Api.method(:get), desc: 'GET request to API path' },
|
|
24
|
+
'groups' => { handler: Commands::Reconfigure.method(:groups), desc: 'List reconfigure groups' },
|
|
25
|
+
'install' => { handler: Commands::Plugin.method(:install), desc: 'Install plugin' },
|
|
26
|
+
'plugins' => { handler: Commands::Plugin.method(:list), desc: 'List installed plugins' },
|
|
27
|
+
'post' => { handler: Commands::Api.method(:post), desc: 'POST request to API path' },
|
|
28
|
+
'reconfigure' => { handler: Commands::Reconfigure.method(:run), desc: 'Trigger service reconfigure' },
|
|
29
|
+
'resources' => { handler: Commands::Resource.method(:list), desc: 'List known resource types' },
|
|
30
|
+
'search' => { handler: Commands::Resource.method(:search), desc: 'Search resources' },
|
|
31
|
+
'show' => { handler: Commands::Resource.method(:show), desc: 'Show single resource' },
|
|
32
|
+
'test' => { handler: Commands::Device.method(:test), desc: 'Test device connectivity' },
|
|
33
|
+
'uninstall' => { handler: Commands::Plugin.method(:uninstall), desc: 'Uninstall plugin' },
|
|
34
|
+
'update' => { handler: Commands::Resource.method(:update), desc: 'Update resource' },
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Main entry point for the CLI.
|
|
40
|
+
#
|
|
41
|
+
# @param argv [Array<String>] Command-line arguments
|
|
42
|
+
# @return [Integer] Exit code (0 = success, 1 = error)
|
|
43
|
+
def run(argv)
|
|
44
|
+
opts = parse_global_options(argv)
|
|
45
|
+
|
|
46
|
+
# Show help if no command given
|
|
47
|
+
if argv.empty?
|
|
48
|
+
show_help
|
|
49
|
+
return 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
command_name = argv.shift
|
|
53
|
+
command = COMMANDS[command_name]
|
|
54
|
+
|
|
55
|
+
unless command
|
|
56
|
+
warn("Unknown command: #{command_name}")
|
|
57
|
+
warn("Run 'opn-api --help' for usage information.")
|
|
58
|
+
return 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Configure logging level
|
|
62
|
+
OpnApi.logger = OpnApi::Logger.new(level: opts[:verbose] ? :debug : :info)
|
|
63
|
+
|
|
64
|
+
# Execute command and format output
|
|
65
|
+
result = command[:handler].call(argv, opts)
|
|
66
|
+
output = Formatter.format(result, format: opts[:format], fields: opts[:fields],
|
|
67
|
+
all_fields: opts[:all_fields],
|
|
68
|
+
show_empty: opts[:show_empty])
|
|
69
|
+
puts output unless output.nil? || output.empty?
|
|
70
|
+
|
|
71
|
+
0
|
|
72
|
+
rescue OpnApi::ApiError => e
|
|
73
|
+
# Show full API response for debugging
|
|
74
|
+
warn("Error: #{e.message}")
|
|
75
|
+
warn("Response body: #{e.body}") if opts[:verbose] && e.body && !e.body.empty?
|
|
76
|
+
1
|
|
77
|
+
rescue OpnApi::Error => e
|
|
78
|
+
warn("Error: #{e.message}")
|
|
79
|
+
1
|
|
80
|
+
rescue JSON::ParserError => e
|
|
81
|
+
warn("Invalid JSON: #{e.message}")
|
|
82
|
+
1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Parses global options from argv (modifies argv in place).
|
|
86
|
+
def parse_global_options(argv)
|
|
87
|
+
opts = { device: 'default', format: :table, verbose: false }
|
|
88
|
+
|
|
89
|
+
parser = OptionParser.new do |o|
|
|
90
|
+
o.banner = 'Usage: opn-api [options] <command> [command-options] [args...]'
|
|
91
|
+
o.separator ''
|
|
92
|
+
o.separator 'Global options:'
|
|
93
|
+
|
|
94
|
+
o.on('-c', '--config-dir PATH', 'Config directory for device files') do |v|
|
|
95
|
+
opts[:config_dir] = v
|
|
96
|
+
end
|
|
97
|
+
o.on('-d', '--device NAME', 'Device name (default: "default")') do |v|
|
|
98
|
+
opts[:device] = v
|
|
99
|
+
end
|
|
100
|
+
o.on('-f', '--format FORMAT', %w[table json yaml],
|
|
101
|
+
'Output format: table, json, yaml (default: table)') do |v|
|
|
102
|
+
opts[:format] = v.to_sym
|
|
103
|
+
end
|
|
104
|
+
o.on('-F', '--fields FIELDS', 'Comma-separated field names for table output') do |v|
|
|
105
|
+
opts[:fields] = v.split(',').map(&:strip)
|
|
106
|
+
end
|
|
107
|
+
o.on('-A', '--all-fields', 'Show all fields in table output (default: first 5)') do
|
|
108
|
+
opts[:all_fields] = true
|
|
109
|
+
end
|
|
110
|
+
o.on('-E', '--show-empty', 'Show empty fields in table output (default: hidden)') do
|
|
111
|
+
opts[:show_empty] = true
|
|
112
|
+
end
|
|
113
|
+
o.on('-v', '--verbose', 'Enable debug output') do
|
|
114
|
+
opts[:verbose] = true
|
|
115
|
+
end
|
|
116
|
+
o.on('--version', 'Show version') do
|
|
117
|
+
puts "opn-api #{OpnApi::VERSION}"
|
|
118
|
+
exit 0
|
|
119
|
+
end
|
|
120
|
+
o.on('-h', '--help', 'Show help') do
|
|
121
|
+
show_help
|
|
122
|
+
exit 0
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Parse global options from anywhere in argv (before or after command)
|
|
127
|
+
parser.parse!(argv)
|
|
128
|
+
opts
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Displays help with available commands.
|
|
132
|
+
def show_help
|
|
133
|
+
puts 'Usage: opn-api [options] <command> [command-options] [args...]'
|
|
134
|
+
puts ''
|
|
135
|
+
puts 'A CLI tool for the OPNsense REST API.'
|
|
136
|
+
puts ''
|
|
137
|
+
puts 'Global options:'
|
|
138
|
+
puts ' -c, --config-dir PATH Config directory for device files'
|
|
139
|
+
puts ' -d, --device NAME Device name (default: "default")'
|
|
140
|
+
puts ' -f, --format FORMAT Output format: table, json, yaml (default: table)'
|
|
141
|
+
puts ' -F, --fields FIELDS Comma-separated field names for table output'
|
|
142
|
+
puts ' -A, --all-fields Show all fields in table output (default: first 5)'
|
|
143
|
+
puts ' -E, --show-empty Show empty fields in table output (default: hidden)'
|
|
144
|
+
puts ' -v, --verbose Enable debug output (includes API error details)'
|
|
145
|
+
puts ' --version Show version'
|
|
146
|
+
puts ' -h, --help Show this help'
|
|
147
|
+
puts ''
|
|
148
|
+
puts 'Commands:'
|
|
149
|
+
max_len = COMMANDS.keys.map(&:length).max
|
|
150
|
+
COMMANDS.each do |name, cmd|
|
|
151
|
+
puts " #{name.ljust(max_len)} #{cmd[:desc]}"
|
|
152
|
+
end
|
|
153
|
+
puts ''
|
|
154
|
+
puts 'Resource commands (search, show, create, update, delete) accept a resource'
|
|
155
|
+
puts 'name from the registry (e.g. "haproxy_server") or raw module/controller/type.'
|
|
156
|
+
puts 'Run "opn-api resources" for a list of known resource types.'
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
|
|
8
|
+
module OpnApi
|
|
9
|
+
# HTTP client for communicating with the OPNsense REST API.
|
|
10
|
+
#
|
|
11
|
+
# Supports SSL, redirect following (301/302/307/308), configurable
|
|
12
|
+
# timeouts, and basic authentication with API key/secret.
|
|
13
|
+
#
|
|
14
|
+
# @example Direct instantiation
|
|
15
|
+
# client = OpnApi::Client.new(
|
|
16
|
+
# url: 'https://fw.example.com/api',
|
|
17
|
+
# api_key: '+ABC...', api_secret: '+XYZ...',
|
|
18
|
+
# ssl_verify: false,
|
|
19
|
+
# )
|
|
20
|
+
# result = client.get('firmware/info/running')
|
|
21
|
+
#
|
|
22
|
+
# @example From Config
|
|
23
|
+
# config = OpnApi::Config.new
|
|
24
|
+
# client = config.client_for('myfw')
|
|
25
|
+
class Client
|
|
26
|
+
DEFAULT_URL = 'http://localhost:80/api'
|
|
27
|
+
MAX_REDIRECTS = 5
|
|
28
|
+
|
|
29
|
+
# @param url [String] Base URL of the OPNsense API (e.g. 'https://fw.example.com/api')
|
|
30
|
+
# @param api_key [String] OPNsense API key
|
|
31
|
+
# @param api_secret [String] OPNsense API secret
|
|
32
|
+
# @param ssl_verify [Boolean] Whether to verify SSL certificates (default: true)
|
|
33
|
+
# @param timeout [Integer] HTTP timeout in seconds (default: 60)
|
|
34
|
+
def initialize(url:, api_key:, api_secret:, ssl_verify: true, timeout: 60)
|
|
35
|
+
@url = url.to_s.chomp('/')
|
|
36
|
+
@api_key = api_key
|
|
37
|
+
@api_secret = api_secret
|
|
38
|
+
@ssl_verify = ssl_verify
|
|
39
|
+
@timeout = timeout.to_i
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Performs an HTTP GET request to the OPNsense API.
|
|
43
|
+
#
|
|
44
|
+
# @param path [String] API path (relative, e.g. 'firewall/alias/search_item')
|
|
45
|
+
# @param raw [Boolean] If true, return raw response body instead of parsing JSON
|
|
46
|
+
# @return [Hash, String] Parsed JSON response, or raw body string when raw: true
|
|
47
|
+
def get(path, raw: false)
|
|
48
|
+
uri = build_uri(path)
|
|
49
|
+
OpnApi.logger.debug("GET #{uri}")
|
|
50
|
+
http_request(:get, uri, nil, 0, raw: raw)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Performs an HTTP POST request to the OPNsense API.
|
|
54
|
+
#
|
|
55
|
+
# @param path [String] API path (relative)
|
|
56
|
+
# @param data [Hash] Request body (serialized as JSON)
|
|
57
|
+
# @return [Hash] Parsed JSON response
|
|
58
|
+
def post(path, data = {})
|
|
59
|
+
uri = build_uri(path)
|
|
60
|
+
OpnApi.logger.debug("POST #{uri} body=#{data.to_json}")
|
|
61
|
+
http_request(:post, uri, data)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Executes an HTTP request, following redirects transparently.
|
|
67
|
+
# OPNsense commonly issues 308 redirects when HTTP is used but HTTPS is required.
|
|
68
|
+
#
|
|
69
|
+
# @param method [Symbol] :get or :post
|
|
70
|
+
# @param uri [URI] Fully qualified URI
|
|
71
|
+
# @param data [Hash, nil] POST body data
|
|
72
|
+
# @param redirect_count [Integer] Internal redirect counter
|
|
73
|
+
# @param raw [Boolean] If true, return raw response body instead of parsing JSON
|
|
74
|
+
# @return [Hash, String] Parsed JSON response, or raw body string when raw: true
|
|
75
|
+
def http_request(method, uri, data = nil, redirect_count = 0, raw: false)
|
|
76
|
+
if redirect_count > MAX_REDIRECTS
|
|
77
|
+
raise OpnApi::ConnectionError, "Too many redirects (> #{MAX_REDIRECTS}) for '#{uri}'"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
http = build_http(uri)
|
|
81
|
+
request = build_request(method, uri, data)
|
|
82
|
+
request.basic_auth(@api_key, @api_secret)
|
|
83
|
+
request['Accept'] = 'application/json'
|
|
84
|
+
request['Content-Type'] = 'application/json' if method == :post
|
|
85
|
+
|
|
86
|
+
response = http.request(request)
|
|
87
|
+
code = response.code.to_i
|
|
88
|
+
|
|
89
|
+
# Handle redirects: 307/308 preserve method+body, 301/302 switch to GET
|
|
90
|
+
if [301, 302, 307, 308].include?(code)
|
|
91
|
+
location = response['location']
|
|
92
|
+
unless location
|
|
93
|
+
raise OpnApi::ConnectionError,
|
|
94
|
+
"#{code} redirect with no Location header for '#{uri}'"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
OpnApi.logger.debug("Following #{code} redirect to '#{location}'")
|
|
98
|
+
new_uri = URI.parse(location)
|
|
99
|
+
|
|
100
|
+
if [307, 308].include?(code)
|
|
101
|
+
return http_request(method, new_uri, data, redirect_count + 1, raw: raw)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return http_request(:get, new_uri, nil, redirect_count + 1, raw: raw)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Raw mode: skip JSON parsing, return body as string
|
|
108
|
+
return handle_raw_response(response, uri.to_s) if raw
|
|
109
|
+
|
|
110
|
+
handle_response(response, uri.to_s)
|
|
111
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT => e
|
|
112
|
+
raise OpnApi::ConnectionError, "Connection failed for '#{uri}': #{e.message}"
|
|
113
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
114
|
+
raise OpnApi::TimeoutError, "Timeout for '#{uri}': #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Builds a full URI from the base URL and a relative path.
|
|
118
|
+
def build_uri(path)
|
|
119
|
+
clean_path = path.to_s.sub(%r{^/+}, '')
|
|
120
|
+
URI.parse("#{@url}/#{clean_path}")
|
|
121
|
+
rescue URI::InvalidURIError => e
|
|
122
|
+
raise OpnApi::ConnectionError, "Invalid API path '#{path}': #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Creates a Net::HTTP instance with SSL and timeout settings.
|
|
126
|
+
def build_http(uri)
|
|
127
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
128
|
+
http.open_timeout = @timeout
|
|
129
|
+
http.read_timeout = @timeout
|
|
130
|
+
|
|
131
|
+
if uri.scheme == 'https'
|
|
132
|
+
http.use_ssl = true
|
|
133
|
+
http.verify_mode = @ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
http
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Builds a Net::HTTP request object for the given method.
|
|
140
|
+
def build_request(method, uri, data)
|
|
141
|
+
case method
|
|
142
|
+
when :get
|
|
143
|
+
Net::HTTP::Get.new(uri)
|
|
144
|
+
when :post
|
|
145
|
+
req = Net::HTTP::Post.new(uri)
|
|
146
|
+
req.body = data.to_json
|
|
147
|
+
req
|
|
148
|
+
else
|
|
149
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Returns raw response body after checking HTTP status.
|
|
154
|
+
# Used for non-JSON endpoints (e.g. XML backup downloads).
|
|
155
|
+
#
|
|
156
|
+
# @param response [Net::HTTPResponse]
|
|
157
|
+
# @param request_uri [String]
|
|
158
|
+
# @return [String] Raw response body
|
|
159
|
+
def handle_raw_response(response, request_uri)
|
|
160
|
+
code = response.code.to_i
|
|
161
|
+
unless (200..299).cover?(code)
|
|
162
|
+
raise OpnApi::ApiError.new(
|
|
163
|
+
"API error #{code} for '#{request_uri}': #{response.body}",
|
|
164
|
+
code: code,
|
|
165
|
+
body: response.body.to_s,
|
|
166
|
+
uri: request_uri,
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
OpnApi.logger.debug("Response #{code} (raw, #{response.body.to_s.bytesize} bytes)")
|
|
171
|
+
response.body.to_s
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Parses and validates an HTTP response.
|
|
175
|
+
#
|
|
176
|
+
# @param response [Net::HTTPResponse]
|
|
177
|
+
# @param request_uri [String]
|
|
178
|
+
# @return [Hash] Parsed JSON body
|
|
179
|
+
def handle_response(response, request_uri)
|
|
180
|
+
code = response.code.to_i
|
|
181
|
+
unless (200..299).cover?(code)
|
|
182
|
+
raise OpnApi::ApiError.new(
|
|
183
|
+
"API error #{code} for '#{request_uri}': #{response.body}",
|
|
184
|
+
code: code,
|
|
185
|
+
body: response.body.to_s,
|
|
186
|
+
uri: request_uri,
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
body = response.body
|
|
191
|
+
return {} if body.nil? || body.strip.empty?
|
|
192
|
+
|
|
193
|
+
OpnApi.logger.debug("Response #{code} body=#{body.strip}")
|
|
194
|
+
JSON.parse(body)
|
|
195
|
+
rescue JSON::ParserError => e
|
|
196
|
+
raise OpnApi::ApiError.new(
|
|
197
|
+
"Response parse error for '#{request_uri}': #{e.message}",
|
|
198
|
+
code: code,
|
|
199
|
+
body: body.to_s,
|
|
200
|
+
uri: request_uri,
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module OpnApi
|
|
6
|
+
# Configuration loader for OPNsense device credentials.
|
|
7
|
+
#
|
|
8
|
+
# Searches for per-device YAML files in a config directory hierarchy.
|
|
9
|
+
# Each YAML file contains connection details for one OPNsense device.
|
|
10
|
+
#
|
|
11
|
+
# Search order (lowest → highest priority):
|
|
12
|
+
# 1. /etc/opn-api/devices/ (system-wide)
|
|
13
|
+
# 2. ~/.config/opn-api/devices/ (per-user)
|
|
14
|
+
# 3. Explicit config_dir parameter (programmatic / CLI)
|
|
15
|
+
# 4. OPN_API_CONFIG_DIR env var (environment override)
|
|
16
|
+
#
|
|
17
|
+
# Device YAML format (compatible with puppet-opn):
|
|
18
|
+
# url: https://192.168.1.1/api
|
|
19
|
+
# api_key: +ABC...
|
|
20
|
+
# api_secret: +XYZ...
|
|
21
|
+
# ssl_verify: false
|
|
22
|
+
# timeout: 60
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# config = OpnApi::Config.new
|
|
26
|
+
# config.device_names # => ['myfw', 'backup']
|
|
27
|
+
# client = config.client_for('myfw')
|
|
28
|
+
#
|
|
29
|
+
# @example With explicit path (e.g. for puppet-opn integration)
|
|
30
|
+
# config = OpnApi::Config.new(config_dir: '/etc/puppetlabs/puppet/opn')
|
|
31
|
+
# client = config.client_for('myfw')
|
|
32
|
+
class Config
|
|
33
|
+
SYSTEM_DIR = '/etc/opn-api/devices'
|
|
34
|
+
USER_DIR = File.join(Dir.home, '.config', 'opn-api', 'devices')
|
|
35
|
+
|
|
36
|
+
# @param config_dir [String, nil] Explicit device config directory.
|
|
37
|
+
# Overrides the default search hierarchy (but not OPN_API_CONFIG_DIR).
|
|
38
|
+
def initialize(config_dir: nil)
|
|
39
|
+
@config_dir = resolve_config_dir(config_dir)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns all available device names found in the config directory.
|
|
43
|
+
#
|
|
44
|
+
# @return [Array<String>] Sorted list of device names
|
|
45
|
+
def device_names
|
|
46
|
+
return [] unless @config_dir && Dir.exist?(@config_dir)
|
|
47
|
+
|
|
48
|
+
Dir.glob(File.join(@config_dir, '*.yaml')).map do |f|
|
|
49
|
+
File.basename(f, '.yaml')
|
|
50
|
+
end.sort
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns the configuration hash for a named device.
|
|
54
|
+
#
|
|
55
|
+
# @param name [String] Device name (filename without .yaml extension)
|
|
56
|
+
# @return [Hash] Device config (url, api_key, api_secret, ssl_verify, timeout)
|
|
57
|
+
# @raise [OpnApi::ConfigError] if config file is missing or malformed
|
|
58
|
+
def device(name)
|
|
59
|
+
path = device_path(name)
|
|
60
|
+
unless File.exist?(path)
|
|
61
|
+
raise OpnApi::ConfigError,
|
|
62
|
+
"Config file not found for device '#{name}': #{path}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
config = YAML.safe_load_file(path)
|
|
66
|
+
unless config.is_a?(Hash)
|
|
67
|
+
raise OpnApi::ConfigError,
|
|
68
|
+
"Config file '#{path}' is not a valid YAML hash"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
config
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Creates a Client instance for the named device.
|
|
75
|
+
#
|
|
76
|
+
# @param name [String] Device name
|
|
77
|
+
# @return [OpnApi::Client]
|
|
78
|
+
# @raise [OpnApi::ConfigError] if config is missing or malformed
|
|
79
|
+
def client_for(name)
|
|
80
|
+
cfg = device(name)
|
|
81
|
+
OpnApi::Client.new(
|
|
82
|
+
url: cfg['url'] || OpnApi::Client::DEFAULT_URL,
|
|
83
|
+
api_key: cfg['api_key'].to_s,
|
|
84
|
+
api_secret: cfg['api_secret'].to_s,
|
|
85
|
+
ssl_verify: cfg.fetch('ssl_verify', true),
|
|
86
|
+
timeout: cfg.fetch('timeout', 60),
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the full path to a device config file.
|
|
91
|
+
#
|
|
92
|
+
# @param name [String] Device name
|
|
93
|
+
# @return [String]
|
|
94
|
+
def device_path(name)
|
|
95
|
+
File.join(@config_dir, "#{name}.yaml")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Resolves the effective config directory from env, explicit arg, or defaults.
|
|
101
|
+
# Priority: OPN_API_CONFIG_DIR > explicit config_dir > first existing default dir
|
|
102
|
+
def resolve_config_dir(explicit_dir)
|
|
103
|
+
env_dir = ENV.fetch('OPN_API_CONFIG_DIR', nil)
|
|
104
|
+
return env_dir if env_dir && !env_dir.empty?
|
|
105
|
+
return explicit_dir if explicit_dir
|
|
106
|
+
|
|
107
|
+
# Use the first default directory that exists, or fall back to USER_DIR
|
|
108
|
+
[USER_DIR, SYSTEM_DIR].find { |d| Dir.exist?(d) } || USER_DIR
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|