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.
data/bin/opn-api ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/opn_api'
5
+ require_relative '../lib/opn_api/cli/main'
6
+
7
+ exit OpnApi::CLI::Main.run(ARGV)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module OpnApi
6
+ module CLI
7
+ module Commands
8
+ # CLI commands for generic API calls (GET/POST).
9
+ module Api
10
+ module_function
11
+
12
+ # Performs a GET request to an arbitrary API path.
13
+ #
14
+ # @param args [Array<String>] [path]
15
+ # @param opts [Hash] Global CLI options
16
+ # @return [Object] Parsed API response
17
+ def get(args, opts)
18
+ path = args.shift
19
+ raise OpnApi::Error, 'Usage: opn-api get <path>' unless path
20
+
21
+ client = Base.build_client(opts)
22
+ client.get(path)
23
+ end
24
+
25
+ # Performs a POST request to an arbitrary API path.
26
+ #
27
+ # @param args [Array<String>] [path, optional_json]
28
+ # @param opts [Hash] Global CLI options
29
+ # @return [Object] Parsed API response
30
+ def post(args, opts)
31
+ path = args.shift
32
+ raise OpnApi::Error, 'Usage: opn-api post <path> [json]' unless path
33
+
34
+ client = Base.build_client(opts)
35
+ data = parse_post_data(args)
36
+ client.post(path, data)
37
+ end
38
+
39
+ # Parses POST data from args or stdin.
40
+ def parse_post_data(args)
41
+ # Inline JSON argument
42
+ json_str = args.shift
43
+ return JSON.parse(json_str) if json_str
44
+
45
+ # Stdin
46
+ unless $stdin.tty?
47
+ input = $stdin.read.strip
48
+ return JSON.parse(input) unless input.empty?
49
+ end
50
+
51
+ # Default: empty body
52
+ {}
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ module CLI
5
+ module Commands
6
+ # CLI command for downloading OPNsense config backups.
7
+ #
8
+ # The backup endpoint returns XML (not JSON), so this uses
9
+ # the client's raw mode to skip JSON parsing.
10
+ module Backup
11
+ module_function
12
+
13
+ # Downloads the OPNsense config backup (XML).
14
+ # Usage: opn-api backup [output_path]
15
+ #
16
+ # With output_path: writes XML to file, returns status hash.
17
+ # Without output_path: returns raw XML string (printed to stdout).
18
+ #
19
+ # @param args [Array<String>] [optional output_path]
20
+ # @param opts [Hash] Global CLI options
21
+ # @return [Hash, String] Status hash or raw XML data
22
+ def download(args, opts)
23
+ client = Base.build_client(opts)
24
+ data = client.get('core/backup/download/this', raw: true)
25
+
26
+ output_path = args.shift
27
+ if output_path
28
+ File.write(output_path, data)
29
+ { 'status' => 'ok', 'file' => output_path, 'size' => data.bytesize }
30
+ else
31
+ # Without output path: raw data directly to stdout
32
+ data
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ module CLI
5
+ module Commands
6
+ # Shared helpers for all CLI commands.
7
+ module Base
8
+ # Creates an OpnApi::Config from global options.
9
+ #
10
+ # @param opts [Hash] Global CLI options
11
+ # @return [OpnApi::Config]
12
+ def self.build_config(opts)
13
+ OpnApi::Config.new(config_dir: opts[:config_dir])
14
+ end
15
+
16
+ # Creates an OpnApi::Client for the device specified in global options.
17
+ #
18
+ # @param opts [Hash] Global CLI options (must include :device)
19
+ # @return [OpnApi::Client]
20
+ def self.build_client(opts)
21
+ config = build_config(opts)
22
+ config.client_for(opts[:device])
23
+ end
24
+
25
+ # Reads JSON input from -j argument or stdin.
26
+ #
27
+ # @param args [Array<String>] Remaining CLI args
28
+ # @return [Hash] Parsed JSON
29
+ def self.read_json_input(args)
30
+ # Check for -j flag in remaining args
31
+ json_idx = args.index('-j')
32
+ if json_idx
33
+ json_str = args[json_idx + 1]
34
+ raise OpnApi::Error, 'Missing JSON data after -j' unless json_str
35
+
36
+ return JSON.parse(json_str)
37
+ end
38
+
39
+ # Read from stdin if not a TTY
40
+ unless $stdin.tty?
41
+ input = $stdin.read.strip
42
+ return JSON.parse(input) unless input.empty?
43
+ end
44
+
45
+ raise OpnApi::Error, 'No JSON input provided (use -j or pipe via stdin)'
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ module CLI
5
+ module Commands
6
+ # CLI commands for device management.
7
+ module Device
8
+ module_function
9
+
10
+ # Lists all configured devices.
11
+ #
12
+ # @param _args [Array<String>] Unused
13
+ # @param opts [Hash] Global CLI options
14
+ # @return [Object] Formatted output data
15
+ def list(_args, opts)
16
+ config = Base.build_config(opts)
17
+ names = config.device_names
18
+ return '(no devices configured)' if names.empty?
19
+
20
+ names.map { |n| { 'device' => n, 'config' => config.device_path(n) } }
21
+ end
22
+
23
+ # Tests connectivity to one or all devices.
24
+ #
25
+ # @param args [Array<String>] Optional device name
26
+ # @param opts [Hash] Global CLI options
27
+ # @return [Object] Formatted output data
28
+ def test(args, opts)
29
+ config = Base.build_config(opts)
30
+ names = args.empty? ? config.device_names : [args.first]
31
+ results = []
32
+
33
+ names.each do |name|
34
+ result = { 'device' => name }
35
+ begin
36
+ client = config.client_for(name)
37
+ # Call a lightweight endpoint to check connectivity
38
+ info = client.get('core/firmware/info')
39
+ result['status'] = 'ok'
40
+ result['version'] = info['product_version'].to_s if info['product_version']
41
+ rescue OpnApi::Error => e
42
+ result['status'] = 'error'
43
+ result['message'] = e.message
44
+ end
45
+ results << result
46
+ end
47
+
48
+ results
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ module CLI
5
+ module Commands
6
+ # CLI commands for OPNsense plugin management.
7
+ #
8
+ # Plugins use the firmware API which does not follow the standard
9
+ # CRUD pattern. These commands provide a user-friendly interface.
10
+ module Plugin
11
+ module_function
12
+
13
+ # Lists installed plugins.
14
+ # Usage: opn-api plugins
15
+ #
16
+ # Fetches the firmware info and filters for installed os-* packages.
17
+ #
18
+ # @param _args [Array<String>] Unused
19
+ # @param opts [Hash] Global CLI options
20
+ # @return [Array<Hash>] List of installed plugins
21
+ def list(_args, opts)
22
+ client = Base.build_client(opts)
23
+ info = client.get('core/firmware/info')
24
+ packages = info['package'] || []
25
+
26
+ # Filter for installed plugin packages (os-* naming convention)
27
+ packages.select { |p| p['installed'] == '1' && p['name'].to_s.start_with?('os-') }
28
+ .map do |p|
29
+ {
30
+ 'name' => p['name'],
31
+ 'version' => p['version'],
32
+ 'comment' => p['comment'],
33
+ }
34
+ end
35
+ end
36
+
37
+ # Installs a plugin.
38
+ # Usage: opn-api install <plugin_name>
39
+ #
40
+ # @param args [Array<String>] [plugin_name]
41
+ # @param opts [Hash] Global CLI options
42
+ # @return [Hash] API response
43
+ def install(args, opts)
44
+ name = args.shift
45
+ raise OpnApi::Error, 'Usage: opn-api install <plugin_name>' unless name
46
+
47
+ client = Base.build_client(opts)
48
+ client.post("core/firmware/install/#{name}", {})
49
+ end
50
+
51
+ # Uninstalls a plugin.
52
+ # Usage: opn-api uninstall <plugin_name>
53
+ #
54
+ # @param args [Array<String>] [plugin_name]
55
+ # @param opts [Hash] Global CLI options
56
+ # @return [Hash] API response
57
+ def uninstall(args, opts)
58
+ name = args.shift
59
+ raise OpnApi::Error, 'Usage: opn-api uninstall <plugin_name>' unless name
60
+
61
+ client = Base.build_client(opts)
62
+ client.post("core/firmware/remove/#{name}", {})
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ module CLI
5
+ module Commands
6
+ # CLI commands for service reconfigure operations.
7
+ module Reconfigure
8
+ module_function
9
+
10
+ # Lists all registered reconfigure groups.
11
+ #
12
+ # @param _args [Array<String>] Unused
13
+ # @param _opts [Hash] Unused
14
+ # @return [Array<Hash>] Group information
15
+ def groups(_args, _opts)
16
+ OpnApi::ServiceReconfigure.registered_names.sort.map do |name|
17
+ OpnApi::ServiceReconfigure[name]
18
+ { 'group' => name.to_s }
19
+ end
20
+ end
21
+
22
+ # Triggers reconfigure for a specific group on a device.
23
+ # Usage: opn-api reconfigure <group>
24
+ #
25
+ # @param args [Array<String>] [group_name]
26
+ # @param opts [Hash] Global CLI options
27
+ # @return [Hash] Reconfigure results
28
+ def run(args, opts)
29
+ group_name = args.shift
30
+ raise OpnApi::Error, 'Usage: opn-api reconfigure <group>' unless group_name
31
+
32
+ client = Base.build_client(opts)
33
+ group = OpnApi::ServiceReconfigure[group_name.to_sym]
34
+ group.mark(opts[:device], client)
35
+ group.run
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpnApi
4
+ module CLI
5
+ module Commands
6
+ # CLI commands for structured resource CRUD operations.
7
+ #
8
+ # Resources can be specified in two ways:
9
+ # 1. Registry name (preferred): opn-api search haproxy_server
10
+ # 2. Raw module/controller/type: opn-api search haproxy settings search_servers
11
+ #
12
+ # The registry handles the inconsistent OPNsense endpoint naming
13
+ # (plural search, camelCase, bare names) transparently.
14
+ #
15
+ # Singleton resources (settings) are auto-detected from the registry
16
+ # and work without UUID for show/update.
17
+ module Resource
18
+ module_function
19
+
20
+ # Searches for resources.
21
+ # Usage: opn-api search <resource_name>
22
+ # opn-api search <module> <controller> <type>
23
+ #
24
+ # @param args [Array<String>] Resource name or [module, controller, type]
25
+ # @param opts [Hash] Global CLI options
26
+ # @return [Object] Search results
27
+ def search(args, opts)
28
+ resource_name = args.first
29
+ res = build_resource(args, opts, 'search')
30
+ result = res.search
31
+ return result unless res.singleton
32
+
33
+ # Singleton: unwrap, filter, normalize (same as show)
34
+ result = result.values.first if result.is_a?(Hash) && result.length == 1 && result.values.first.is_a?(Hash)
35
+ result = filter_singleton_response(resource_name, result) if result.is_a?(Hash)
36
+ OpnApi::Normalize.normalize_config(result)
37
+ end
38
+
39
+ # Shows a single resource by UUID, or a singleton settings resource.
40
+ # Usage: opn-api show <resource_name> <uuid>
41
+ # opn-api show <singleton_resource>
42
+ #
43
+ # @param args [Array<String>] Resource name + uuid, or singleton name
44
+ # @param opts [Hash] Global CLI options
45
+ # @return [Hash] Resource data
46
+ def show(args, opts)
47
+ # Capture resource name before it's shifted from args
48
+ resource_name = args.first
49
+ res, remaining = build_resource_with_remainder(args, opts, 'show')
50
+
51
+ result = if res.singleton
52
+ res.show_settings
53
+ else
54
+ uuid = remaining.shift
55
+ raise OpnApi::Error, "Usage: opn-api show <resource> <uuid>\n#{registry_hint}" unless uuid
56
+
57
+ res.get(uuid)
58
+ end
59
+
60
+ # Unwrap single-key response (e.g. {"alias": {...}} → inner hash)
61
+ result = result.values.first if result.is_a?(Hash) && result.length == 1 && result.values.first.is_a?(Hash)
62
+
63
+ # Filter singleton response to settings-only keys (matching puppet-opn behavior)
64
+ result = filter_singleton_response(resource_name, result) if res.singleton && result.is_a?(Hash)
65
+
66
+ # Normalize selection hashes for human-readable output
67
+ OpnApi::Normalize.normalize_config(result)
68
+ end
69
+
70
+ # Creates a new resource.
71
+ # Usage: opn-api create <resource_name> [-j json | stdin]
72
+ #
73
+ # @param args [Array<String>] Resource name, then JSON
74
+ # @param opts [Hash] Global CLI options
75
+ # @return [Hash] API response
76
+ def create(args, opts)
77
+ res, remaining = build_resource_with_remainder(args, opts, 'create')
78
+ raise OpnApi::Error, 'Singleton resources do not support create. Use update instead.' if res.singleton
79
+
80
+ data = Base.read_json_input(remaining)
81
+ result = res.add(data)
82
+ check_result(result, 'create', args)
83
+ result
84
+ end
85
+
86
+ # Updates an existing resource by UUID, or a singleton settings resource.
87
+ # Usage: opn-api update <resource_name> <uuid> [-j json | stdin]
88
+ # opn-api update <singleton_resource> [-j json | stdin]
89
+ #
90
+ # @param args [Array<String>] Resource + uuid, then JSON
91
+ # @param opts [Hash] Global CLI options
92
+ # @return [Hash] API response
93
+ def update(args, opts)
94
+ res, remaining = build_resource_with_remainder(args, opts, 'update')
95
+
96
+ if res.singleton
97
+ data = Base.read_json_input(remaining)
98
+ result = res.update_settings(data)
99
+ else
100
+ uuid = remaining.shift
101
+ raise OpnApi::Error, "Usage: opn-api update <resource> <uuid> -j '<json>'" unless uuid
102
+
103
+ data = Base.read_json_input(remaining)
104
+ result = res.set(uuid, data)
105
+ end
106
+
107
+ check_result(result, 'update', args)
108
+ result
109
+ end
110
+
111
+ # Deletes a resource by UUID.
112
+ # Usage: opn-api delete <resource_name> <uuid>
113
+ #
114
+ # @param args [Array<String>] Resource + uuid
115
+ # @param opts [Hash] Global CLI options
116
+ # @return [Hash] API response
117
+ def delete(args, opts)
118
+ res, remaining = build_resource_with_remainder(args, opts, 'delete')
119
+ raise OpnApi::Error, 'Singleton resources do not support delete.' if res.singleton
120
+
121
+ uuid = remaining.shift
122
+ raise OpnApi::Error, 'Usage: opn-api delete <resource> <uuid>' unless uuid
123
+
124
+ result = res.del(uuid)
125
+ check_result(result, 'delete', args)
126
+ result
127
+ end
128
+
129
+ # Lists all known resource types from the registry.
130
+ # Usage: opn-api resources
131
+ #
132
+ # @param _args [Array<String>] Unused
133
+ # @param _opts [Hash] Unused
134
+ # @return [Array<Hash>] Resource type information
135
+ def list(_args, _opts)
136
+ OpnApi::ResourceRegistry.names.map do |name|
137
+ entry = OpnApi::ResourceRegistry.lookup(name)
138
+ type = entry[:singleton] ? 'singleton' : 'crud'
139
+ { 'resource' => name, 'wrapper_key' => entry[:wrapper].to_s, 'type' => type }
140
+ end
141
+ end
142
+
143
+ # Checks the API result for failure and raises with the full API response.
144
+ def check_result(result, action, args)
145
+ return unless result.is_a?(Hash)
146
+
147
+ status = (result['result'] || result['status']).to_s.strip.downcase
148
+ return unless status == 'failed'
149
+
150
+ msg = "#{action} failed: #{JSON.generate(result)}"
151
+
152
+ # When OPNsense returns only {"result":"failed"} without validation
153
+ # details, the most common cause is a wrong wrapper key in the JSON body.
154
+ if result.keys == ['result']
155
+ wrapper_hint = detect_wrapper_key(args)
156
+ msg += "\nHint: The JSON wrapper key is likely wrong. "
157
+ msg += "Expected wrapper key: '#{wrapper_hint}'. " if wrapper_hint
158
+ msg += 'The endpoint type is NOT the wrapper key. ' unless wrapper_hint
159
+ msg += 'Use "opn-api show -f json" on an existing resource to find the correct wrapper key. '
160
+ msg += 'Use -v for full request/response details.'
161
+ end
162
+
163
+ raise OpnApi::Error, msg
164
+ end
165
+
166
+ # Tries to detect the correct wrapper key from args.
167
+ def detect_wrapper_key(args)
168
+ name = args.is_a?(Array) ? args.first : nil
169
+ return nil unless name
170
+
171
+ entry = OpnApi::ResourceRegistry.lookup(name)
172
+ entry&.dig(:wrapper)
173
+ end
174
+
175
+ # Builds a Resource from args.
176
+ def build_resource(args, opts, command)
177
+ build_resource_with_remainder(args, opts, command).first
178
+ end
179
+
180
+ # Builds a Resource and returns [resource, remaining_args].
181
+ # Tries registry lookup first, then raw mode.
182
+ def build_resource_with_remainder(args, opts, command)
183
+ raise OpnApi::Error, "Usage: opn-api #{command} <resource>\n#{registry_hint}" if args.empty?
184
+
185
+ client = Base.build_client(opts)
186
+
187
+ # Try registry lookup (single arg)
188
+ entry = OpnApi::ResourceRegistry.lookup(args.first)
189
+ if entry
190
+ name = args.shift
191
+ res = OpnApi::ResourceRegistry.build(client, name)
192
+ return [res, args]
193
+ end
194
+
195
+ # Fallback: raw module/controller/type (three args)
196
+ if args.length >= 3 && !args[0].start_with?('-')
197
+ mod = args.shift
198
+ ctrl = args.shift
199
+ type = args.shift
200
+ res = OpnApi::Resource.new(
201
+ client: client,
202
+ module_name: mod,
203
+ controller: ctrl,
204
+ resource_type: type,
205
+ )
206
+ return [res, args]
207
+ end
208
+
209
+ raise OpnApi::Error,
210
+ "Unknown resource: '#{args.first}'. " \
211
+ "Run 'opn-api resources' for known types, or use three args: <module> <controller> <type>"
212
+ end
213
+
214
+ # Filters a singleton response to only include settings keys,
215
+ # excluding sub-resource data. Matches puppet-opn behavior:
216
+ # - response_dig with 1 element → dig into that sub-key
217
+ # - response_dig with 2+ elements → slice those keys
218
+ # - response_reject → reject those keys
219
+ # - neither → return unfiltered
220
+ def filter_singleton_response(resource_name, result)
221
+ entry = OpnApi::ResourceRegistry.lookup(resource_name)
222
+ return result unless entry
223
+
224
+ if entry[:response_dig]
225
+ keys = entry[:response_dig]
226
+ if keys.length == 1
227
+ # Single key: dig into sub-key (e.g. acmeclient → settings)
228
+ result[keys.first] || result
229
+ else
230
+ # Multiple keys: slice those settings sections
231
+ result.slice(*keys)
232
+ end
233
+ elsif entry[:response_reject]
234
+ # Reject sub-resource keys (e.g. zabbix_agent → reject aliases, userparameters)
235
+ result.except(*entry[:response_reject])
236
+ else
237
+ result
238
+ end
239
+ end
240
+
241
+ # Hint text for error messages.
242
+ def registry_hint
243
+ "Run 'opn-api resources' for a list of known resource types."
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end