licentivo-sdk 0.10.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/LICENSE +21 -0
- data/NOTICE +17 -0
- data/README.md +80 -0
- data/build_native.rb +48 -0
- data/examples/app.rb +56 -0
- data/examples/quickstart.rb +32 -0
- data/examples/vectors.rb +10 -0
- data/ext/licentivo_native/extconf.rb +27 -0
- data/lib/licentivo.rb +231 -0
- data/native/feature_tasks.h +72 -0
- data/native/licentivo.c +370 -0
- data/native/licentivo.h +64 -0
- data/native/vendor/Monocypher-LICENCE.md +173 -0
- data/native/vendor/cJSON-LICENSE +20 -0
- data/native/vendor/cJSON.c +3191 -0
- data/native/vendor/cJSON.h +306 -0
- data/native/vendor/monocypher-ed25519.c +500 -0
- data/native/vendor/monocypher-ed25519.h +140 -0
- data/native/vendor/monocypher.c +2956 -0
- data/native/vendor/monocypher.h +321 -0
- metadata +71 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 998ed5c517528b8aa65ef3c83722dbcf39ad178e5c0a0008983ccd9837bfaf3a
|
|
4
|
+
data.tar.gz: ca64986426a30cfa1c96baeba82af59b8ad1b24b6051d22f5e192d8f886b0930
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 13f47cb98e670d8c315359af3290721410b733258fbbe0fce5d65b253eac574500276373be935649ff5b14826f51d1860cebb9663da500bf2ab302a10dcd5c61
|
|
7
|
+
data.tar.gz: 694ec4cf2631aef6abe1596ded72a0201b480f64235a13f12f750bca4c211397a7c0dabd62ed85e4a4c831fe95f40acf16d627ef7956c0a78f0a683b008b9b43
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 spf86 and Licentivo contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/NOTICE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Licentivo client SDKs
|
|
2
|
+
Copyright (c) 2026 spf86 and Licentivo contributors
|
|
3
|
+
Project: https://github.com/spf86/licentivo-sdk
|
|
4
|
+
|
|
5
|
+
The MIT license applies to the Licentivo client SDK code, examples and
|
|
6
|
+
installation tools. The licensing server and administration application are
|
|
7
|
+
separate software and are not included in this SDK distribution.
|
|
8
|
+
|
|
9
|
+
Bundled third-party source retains its original license:
|
|
10
|
+
- cJSON: see sdk/c/vendor/cJSON-LICENSE (or c/vendor/cJSON-LICENSE).
|
|
11
|
+
- Monocypher and its Ed25519 extension: see
|
|
12
|
+
sdk/c/vendor/Monocypher-LICENCE.md (or c/vendor/Monocypher-LICENCE.md).
|
|
13
|
+
Native packages include these files in the licenses directory.
|
|
14
|
+
|
|
15
|
+
External dependencies such as libcurl, OpenSSL, cryptography, undici and
|
|
16
|
+
BouncyCastle retain their own licenses. Their installation is handled by
|
|
17
|
+
the corresponding package manager or the target system.
|
data/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Licentivo Ruby SDK
|
|
2
|
+
|
|
3
|
+
Add software licensing to a Ruby application with a block-based interface and a bundled native licensing core.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Activates or restores a license with a private machine-scoped device credential.
|
|
8
|
+
- Verifies pinned signatures, checks expiry and feature access, and protects your business block.
|
|
9
|
+
- Runs policy heartbeats, supports permitted offline use and floating-seat cleanup.
|
|
10
|
+
- Handles metered-feature reserve/commit/cancel and journals pending settlement across restarts.
|
|
11
|
+
- Supports refresh, explicit device deactivation, offline file activation and structured error diagnostics.
|
|
12
|
+
|
|
13
|
+
Your application provides the activation interface, the actual business block and durable business results. The SDK manages licensing; customer credentials and vendor management keys do not belong in distributed packages.
|
|
14
|
+
|
|
15
|
+
## Installation and runtime requirements
|
|
16
|
+
|
|
17
|
+
Start with the repository's `QUICKSTART.md`. The application in
|
|
18
|
+
`examples/quickstart.rb` activates/restores a device and actually exports a file.
|
|
19
|
+
It also supports `LICENTIVO_METERED=1` with a stable `LICENTIVO_OPERATION_ID`.
|
|
20
|
+
|
|
21
|
+
Prerequisites: Ruby 3.1+ with Fiddle, a C11 compiler and libcurl development files.
|
|
22
|
+
Linux also needs OpenSSL development files. Installing a source gem requires make
|
|
23
|
+
(RubyInstaller Devkit/MSYS2 on Windows). A matching precompiled platform gem
|
|
24
|
+
includes the core and installs without a C compiler; Windows also includes the
|
|
25
|
+
matching libcurl DLL and its license. The platform gem still requires Fiddle.
|
|
26
|
+
The source launcher builds the bundled
|
|
27
|
+
core automatically. For a source gem, RubyGems builds it during installation.
|
|
28
|
+
Windows uses MinGW and a matching libcurl package; set `LICENTIVO_CURL_ROOT` and add
|
|
29
|
+
its `bin` directory to the process's PATH. Physical macOS acceptance is pending.
|
|
30
|
+
|
|
31
|
+
Install the pinned RubyGems release, or add it to your application's Gemfile:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
gem install licentivo-sdk --version 0.10.0
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
gem 'licentivo-sdk', '= 0.10.0'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The [common website installer](https://www.licentivo.com/en/docs.html#sdk) with `ruby` also selects and installs a verified matching platform gem. It prints the per-terminal GEM_PATH command; platform gems avoid a local C build. A complete local source bundle can also be used:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# Gemfile
|
|
45
|
+
gem 'licentivo-sdk', path: 'SDK_ROOT/sdk/ruby'
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For a path gem, run `ruby SDK_ROOT/sdk/ruby/build_native.rb` before starting your
|
|
49
|
+
application. A built `.gem` runs this compilation automatically during `gem install`.
|
|
50
|
+
The SDK does not download executable code when you require it.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
require 'licentivo'
|
|
54
|
+
|
|
55
|
+
Licentivo::Client.open_with_license('product.json', customer_key) do |client|
|
|
56
|
+
client.run_feature('export') { File.write('report.txt', "Your result\n") }
|
|
57
|
+
# A real application keeps client alive through its main loop.
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`run_metered_feature('export', 1, job_id) { your_operation }` handles reserve,
|
|
62
|
+
business execution, commit and cancellation. It returns an `Execution` with
|
|
63
|
+
`executed` and `value`; `replay?` means the business block was skipped and you
|
|
64
|
+
should load its existing result using the job ID. Exceptions are re-raised after
|
|
65
|
+
the core handles cancellation. If the process dies during the block, check the
|
|
66
|
+
real business outcome and call `resolve_metered_feature(job_id, true_or_false)`.
|
|
67
|
+
|
|
68
|
+
Use one distinct stable ID per job; separate processes must not execute the same
|
|
69
|
+
ID. Do not close or start another workflow from inside a business block. Status
|
|
70
|
+
reads are available there. The client serializes native access; policy heartbeats
|
|
71
|
+
run inside the core without entering Ruby. Poll `status` or `feature_status` for
|
|
72
|
+
your UI and marshal updates to its thread.
|
|
73
|
+
|
|
74
|
+
`Licentivo::Error` exposes `code`, `http_status`, `request_id`, `retryable` and
|
|
75
|
+
`retry_after_seconds`. Online refresh, explicit deactivate, offline exchange and
|
|
76
|
+
low-level metering methods are also provided. Use `close` or the owning block's
|
|
77
|
+
ensure cleanup on shutdown; normal exits retain node-locked device registration.
|
|
78
|
+
|
|
79
|
+
Do not ship test keys, `sdk-demo.json`, credentials, caches or management API keys.
|
|
80
|
+
TLS and pinned signatures stay enabled. Licenses accompany the vendored core.
|
data/build_native.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Compiles the shared licensing core without downloading anything or modifying
|
|
3
|
+
# system settings. Source bundles ship canonical copies under native/.
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'rbconfig'
|
|
7
|
+
require 'shellwords'
|
|
8
|
+
|
|
9
|
+
package = File.expand_path(__dir__)
|
|
10
|
+
bundled = File.join(package, 'native')
|
|
11
|
+
core = File.file?(File.join(bundled, 'licentivo.c')) ? bundled : File.expand_path('../c', package)
|
|
12
|
+
windows = RbConfig::CONFIG['host_os'].match?(/mswin|mingw/)
|
|
13
|
+
mac = RbConfig::CONFIG['host_os'].include?('darwin')
|
|
14
|
+
extension = windows ? 'dll' : mac ? 'dylib' : 'so'
|
|
15
|
+
output = ARGV[0] || File.join(package, 'lib/licentivo/native', "licentivo_native.#{extension}")
|
|
16
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
17
|
+
compiler = Shellwords.split(ENV.fetch('CC', windows ? 'gcc' : 'cc'))
|
|
18
|
+
command = compiler + ['-std=c11', '-O2', "-I#{core}"]
|
|
19
|
+
command << '-fPIC' unless windows
|
|
20
|
+
command << (mac ? '-dynamiclib' : '-shared')
|
|
21
|
+
command << '-Wl,--export-all-symbols' if windows
|
|
22
|
+
curl = ENV['LICENTIVO_CURL_ROOT']
|
|
23
|
+
if curl && !curl.empty?
|
|
24
|
+
command << "-I#{File.join(curl, 'include')}"
|
|
25
|
+
command << "-L#{File.join(curl, 'lib')}"
|
|
26
|
+
elsif windows
|
|
27
|
+
abort 'Set LICENTIVO_CURL_ROOT to your matching libcurl development folder. See README.md.'
|
|
28
|
+
elsif system('pkg-config', '--exists', 'libcurl', 'openssl', out: File::NULL, err: File::NULL)
|
|
29
|
+
flags, status = Open3.capture2('pkg-config', '--cflags', '--libs', 'libcurl', 'openssl')
|
|
30
|
+
abort 'pkg-config failed' unless status.success?
|
|
31
|
+
# Library options belong after the object/source files in the linker command.
|
|
32
|
+
includes, libraries = Shellwords.split(flags).partition { |flag| flag.start_with?('-I', '-D') }
|
|
33
|
+
command.concat(includes)
|
|
34
|
+
end
|
|
35
|
+
command.concat(%w[licentivo.c vendor/cJSON.c vendor/monocypher.c vendor/monocypher-ed25519.c].map { |file| File.join(core, file) })
|
|
36
|
+
command.concat(libraries || ['-lcurl'])
|
|
37
|
+
command.concat(windows ? %w[-lbcrypt -ladvapi32] : %w[-lcrypto -lpthread -lm])
|
|
38
|
+
command.concat(['-o', output])
|
|
39
|
+
abort 'Native core build failed. Install a C11 compiler and libcurl/OpenSSL development files.' unless system(*command)
|
|
40
|
+
if windows && curl
|
|
41
|
+
dlls = Dir[File.join(File.expand_path(curl), 'bin', 'libcurl*.dll')]
|
|
42
|
+
abort 'The matching runtime libcurl DLL is missing' if dlls.empty?
|
|
43
|
+
dlls.each { |dll| FileUtils.cp(dll, File.dirname(output)) }
|
|
44
|
+
license = [File.join(curl, 'COPYING'), File.join(curl, 'COPYING.txt')].find { |file| File.file?(file) }
|
|
45
|
+
abort 'The libcurl license is missing' unless license
|
|
46
|
+
FileUtils.cp(license, File.join(File.dirname(output), 'libcurl-LICENSE'))
|
|
47
|
+
end
|
|
48
|
+
puts "Native licensing core built: #{output}"
|
data/examples/app.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative '../lib/licentivo'
|
|
3
|
+
|
|
4
|
+
def ask(label)
|
|
5
|
+
print label
|
|
6
|
+
$stdout.flush
|
|
7
|
+
$stdin.gets&.strip || ''
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def export_report
|
|
11
|
+
File.write('licensed-report.txt', "A report generated by the licensed application.\n")
|
|
12
|
+
puts 'Saved licensed-report.txt'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
config = ARGV[0] || 'product.json'
|
|
16
|
+
key = ENV.fetch('LICENTIVO_LICENSE_KEY', '')
|
|
17
|
+
client = nil
|
|
18
|
+
begin
|
|
19
|
+
begin
|
|
20
|
+
client = Licentivo::Client.open_with_license(config, key)
|
|
21
|
+
rescue Licentivo::Error => error
|
|
22
|
+
warn error.message
|
|
23
|
+
key = ask('Customer activation key (not a management API key): ')
|
|
24
|
+
client = Licentivo::Client.open_with_license(config, key)
|
|
25
|
+
end
|
|
26
|
+
loop do
|
|
27
|
+
puts "\n1 Status | 2 Export | 3 Metered export | 4 Refresh | 5 Transfer device | 0 Quit"
|
|
28
|
+
choice = ask('> ')
|
|
29
|
+
break if choice == '0' || choice.empty?
|
|
30
|
+
begin
|
|
31
|
+
case choice
|
|
32
|
+
when '1' then puts JSON.pretty_generate(client.status)
|
|
33
|
+
when '2' then client.run_feature('export') { export_report }
|
|
34
|
+
when '3'
|
|
35
|
+
id = ask('Stable job ID (reuse only to recover this same job): ')
|
|
36
|
+
result = client.run_metered_feature('export', 1, id) { export_report }
|
|
37
|
+
puts 'Existing job confirmed; export was not repeated' if result.replay?
|
|
38
|
+
when '4' then client.refresh_online
|
|
39
|
+
when '5'
|
|
40
|
+
if ask('Type TRANSFER to explicitly unbind this device: ') == 'TRANSFER'
|
|
41
|
+
client.deactivate
|
|
42
|
+
puts 'Device unbound; reactivate on the destination device'
|
|
43
|
+
break
|
|
44
|
+
end
|
|
45
|
+
else puts 'Choose a menu number'
|
|
46
|
+
end
|
|
47
|
+
rescue StandardError => error
|
|
48
|
+
warn error.message
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
rescue StandardError => error
|
|
52
|
+
warn error.message
|
|
53
|
+
exit 2
|
|
54
|
+
ensure
|
|
55
|
+
client&.close # Always stop heartbeat; normal exit retains device registration.
|
|
56
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative '../lib/licentivo'
|
|
3
|
+
|
|
4
|
+
def setting(name, fallback)
|
|
5
|
+
value = ENV[name]
|
|
6
|
+
value.nil? || value.empty? ? fallback : value
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def export_report(path)
|
|
10
|
+
# Replace this write with your existing export operation.
|
|
11
|
+
File.write(path, "A report generated by the licensed application.\n")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
# A GUI supplies the activation input. Later starts use an empty key.
|
|
16
|
+
Licentivo::Client.open_with_license(
|
|
17
|
+
setting('LICENTIVO_CONFIG', 'product.json'),
|
|
18
|
+
setting('LICENTIVO_LICENSE_KEY', '')
|
|
19
|
+
) do |client|
|
|
20
|
+
feature = setting('LICENTIVO_FEATURE', 'export')
|
|
21
|
+
path = setting('LICENTIVO_OUTPUT', 'licensed-report.txt')
|
|
22
|
+
if setting('LICENTIVO_METERED', '0') == '1'
|
|
23
|
+
client.run_metered_feature(feature, 1, setting('LICENTIVO_OPERATION_ID', '')) { export_report(path) }
|
|
24
|
+
else
|
|
25
|
+
client.run_feature(feature) { export_report(path) }
|
|
26
|
+
end
|
|
27
|
+
puts "License OK: #{feature} is available\nExport completed: #{path}"
|
|
28
|
+
end # Real app: keep the owning block/client alive until shutdown.
|
|
29
|
+
rescue StandardError => error
|
|
30
|
+
warn error.message
|
|
31
|
+
exit 2
|
|
32
|
+
end
|
data/examples/vectors.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative '../lib/licentivo'
|
|
3
|
+
|
|
4
|
+
fixture = JSON.parse(File.read(ARGV[0] || 'tests/lease-vectors.json'))
|
|
5
|
+
fixture.fetch('vectors').each do |vector|
|
|
6
|
+
actual = Licentivo.verify_lease(vector['envelope'], fixture['trusted_keys'],
|
|
7
|
+
vector['product_id'], vector['device_id'], vector['now'])
|
|
8
|
+
abort "Ruby vector failed: #{vector['name']}" unless actual == vector['valid']
|
|
9
|
+
end
|
|
10
|
+
puts "Ruby security core: #{fixture['vectors'].length} lease vectors passed"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'mkmf'
|
|
3
|
+
|
|
4
|
+
package = File.expand_path('../..', __dir__)
|
|
5
|
+
core = File.join(package, 'native')
|
|
6
|
+
abort 'Bundled native core is missing' unless File.file?(File.join(core, 'licentivo.c'))
|
|
7
|
+
$INCFLAGS << " -I#{Shellwords.escape(core)}"
|
|
8
|
+
$VPATH.concat([core, File.join(core, 'vendor')])
|
|
9
|
+
$srcs = %w[licentivo.c cJSON.c monocypher.c monocypher-ed25519.c]
|
|
10
|
+
$CFLAGS << ' -std=c11'
|
|
11
|
+
curl = ENV['LICENTIVO_CURL_ROOT']
|
|
12
|
+
if curl && !curl.empty?
|
|
13
|
+
dir_config('curl', File.join(curl, 'include'), File.join(curl, 'lib'))
|
|
14
|
+
end
|
|
15
|
+
abort 'libcurl development files are required' unless have_header('curl/curl.h') && have_library('curl', 'curl_easy_init')
|
|
16
|
+
if RbConfig::CONFIG['host_os'].match?(/mswin|mingw/)
|
|
17
|
+
$DLDFLAGS << ' -Wl,--export-all-symbols'
|
|
18
|
+
have_library('bcrypt')
|
|
19
|
+
have_library('advapi32')
|
|
20
|
+
else
|
|
21
|
+
abort 'OpenSSL development files are required' unless have_header('openssl/evp.h') && have_library('crypto', 'EVP_sha256')
|
|
22
|
+
have_library('pthread')
|
|
23
|
+
have_library('m')
|
|
24
|
+
end
|
|
25
|
+
# Fiddle loads this library directly. It contains no Ruby C API calls or Init_*
|
|
26
|
+
# hook; the target path is also used by the standalone source build script.
|
|
27
|
+
create_makefile('licentivo/native/licentivo_native')
|
data/lib/licentivo.rb
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'fiddle'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'monitor'
|
|
5
|
+
|
|
6
|
+
module Licentivo
|
|
7
|
+
VERSION = '0.10.0'
|
|
8
|
+
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
attr_reader :code, :http_status, :request_id, :retryable, :retry_after_seconds
|
|
11
|
+
def initialize(message, code: 'SDK_ERROR', http_status: 0, request_id: '', retryable: false, retry_after_seconds: 0)
|
|
12
|
+
@code, @http_status, @request_id = code, http_status, request_id
|
|
13
|
+
@retryable, @retry_after_seconds = retryable, retry_after_seconds
|
|
14
|
+
super("#{code}: #{message}#{request_id.empty? ? '' : " (request #{request_id})"}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Execution = Struct.new(:value, :executed, keyword_init: true) do
|
|
19
|
+
def replay? = !executed
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# This library does not register Ruby callbacks on the native heartbeat thread.
|
|
23
|
+
# Local status reads can be marshalled by the application to its own UI thread.
|
|
24
|
+
module Native
|
|
25
|
+
module_function
|
|
26
|
+
def library
|
|
27
|
+
@library ||= begin
|
|
28
|
+
suffix = case RUBY_PLATFORM
|
|
29
|
+
when /mswin|mingw/ then 'dll'
|
|
30
|
+
when /darwin/ then 'dylib'
|
|
31
|
+
else 'so'
|
|
32
|
+
end
|
|
33
|
+
folder = File.expand_path('licentivo/native', __dir__)
|
|
34
|
+
path = ENV['LICENTIVO_NATIVE_LIBRARY'] || [suffix, 'so', 'dll', 'dylib'].uniq.map { |ext| File.join(folder, "licentivo_native.#{ext}") }.find { |file| File.file?(file) }
|
|
35
|
+
path ||= File.join(folder, "licentivo_native.#{suffix}")
|
|
36
|
+
unless File.file?(path)
|
|
37
|
+
raise LoadError, 'Licentivo native core is missing. Run ruby sdk/ruby/build_native.rb for a source bundle, or install the compiled gem. See sdk/ruby/README.md.'
|
|
38
|
+
end
|
|
39
|
+
if suffix == 'dll'
|
|
40
|
+
# Ruby's Windows loader does not search process PATH for dependent
|
|
41
|
+
# DLLs. Load the matching libcurl explicitly and keep its handle alive.
|
|
42
|
+
candidates = Dir[File.join(File.dirname(path), 'libcurl*.dll')]
|
|
43
|
+
if ENV['LICENTIVO_CURL_ROOT']
|
|
44
|
+
candidates.concat(Dir[File.join(File.expand_path(ENV['LICENTIVO_CURL_ROOT']), 'bin', 'libcurl*.dll')])
|
|
45
|
+
end
|
|
46
|
+
@curl_library = Fiddle.dlopen(candidates.first) unless candidates.empty?
|
|
47
|
+
end
|
|
48
|
+
Fiddle.dlopen(path)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
def bind(name, arguments, result)
|
|
52
|
+
# Release the GVL while the core uses the network. Fiddle closures acquire
|
|
53
|
+
# it again before entering a Ruby block; native heartbeats never enter Ruby.
|
|
54
|
+
Fiddle::Function.new(library[name], arguments, result, need_gvl: false)
|
|
55
|
+
end
|
|
56
|
+
def functions
|
|
57
|
+
@functions ||= begin
|
|
58
|
+
p, i, q = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_LONG_LONG
|
|
59
|
+
signatures = {
|
|
60
|
+
init: ['ln_init_file_with_license', [p,p], p],
|
|
61
|
+
start: ['ln_start', [p], i], destroy: ['ln_destroy', [p], Fiddle::TYPE_VOID],
|
|
62
|
+
last_error: ['ln_last_error', [p], p], error_info: ['ln_get_error_info', [p,p], i],
|
|
63
|
+
status: ['ln_status', [p], p], feature_status: ['ln_feature_status', [p,p], p],
|
|
64
|
+
run_feature: ['ln_run_feature', [p,p,p,p], i],
|
|
65
|
+
run_metered_feature: ['ln_run_metered_feature', [p,p,q,p,p,p], i],
|
|
66
|
+
resolve_metered_feature: ['ln_resolve_metered_feature', [p,p,i], i],
|
|
67
|
+
validate: ['ln_validate', [p], i], heartbeat: ['ln_heartbeat', [p], i],
|
|
68
|
+
refresh_online: ['ln_refresh_online', [p], i], deactivate: ['ln_deactivate', [p], i],
|
|
69
|
+
reserve: ['ln_reserve', [p,p,q,p], p], consume: ['ln_consume', [p,p,q,p], p],
|
|
70
|
+
commit: ['ln_commit', [p,p,p], p], cancel: ['ln_cancel', [p,p,p], p],
|
|
71
|
+
offline_request: ['ln_offline_request', [p,p], p], import_offline: ['ln_import_offline', [p,p,p], i],
|
|
72
|
+
free_string: ['ln_free_string', [p], Fiddle::TYPE_VOID],
|
|
73
|
+
json: ['ln_json', [p], p], delete_json: ['cJSON_Delete', [p], Fiddle::TYPE_VOID],
|
|
74
|
+
verify: ['ln_verify', [p,p,p,p,q], i]
|
|
75
|
+
}
|
|
76
|
+
signatures.transform_values { |name, args, result| bind(name, args, result) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
def call(name, *arguments) = functions.fetch(name).call(*arguments)
|
|
80
|
+
def input(value)
|
|
81
|
+
value = String(value)
|
|
82
|
+
raise ArgumentError, 'Input must not contain NUL bytes' if value.include?("\0")
|
|
83
|
+
value + "\0"
|
|
84
|
+
end
|
|
85
|
+
def text(pointer) = pointer.null? ? '' : pointer.to_s.force_encoding(Encoding::UTF_8)
|
|
86
|
+
def owned_json(pointer)
|
|
87
|
+
raise Error.new('Native core returned no result', code: 'INVALID_RESPONSE') if pointer.null?
|
|
88
|
+
begin
|
|
89
|
+
JSON.parse(pointer.to_s)
|
|
90
|
+
ensure
|
|
91
|
+
call(:free_string, pointer)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class Client
|
|
97
|
+
def self.open_with_license(path, customer_key = '')
|
|
98
|
+
client = new(path, customer_key)
|
|
99
|
+
begin
|
|
100
|
+
client.start
|
|
101
|
+
rescue Exception
|
|
102
|
+
client.close
|
|
103
|
+
raise
|
|
104
|
+
end
|
|
105
|
+
return client unless block_given?
|
|
106
|
+
begin
|
|
107
|
+
yield client
|
|
108
|
+
ensure
|
|
109
|
+
client.close
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def initialize(path, customer_key = '')
|
|
114
|
+
@lock, @in_callback = Monitor.new, false
|
|
115
|
+
@native = Native.call(:init, Native.input(path), Native.input(customer_key))
|
|
116
|
+
if @native.null?
|
|
117
|
+
@native = nil
|
|
118
|
+
raise Error.new('Check product.json, pinned keys and local storage', code: 'CONFIGURATION_ERROR')
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def start = checked(:start)
|
|
123
|
+
def validate = checked(:validate)
|
|
124
|
+
def heartbeat = checked(:heartbeat)
|
|
125
|
+
def refresh_online = checked(:refresh_online)
|
|
126
|
+
def deactivate = checked(:deactivate)
|
|
127
|
+
def status = read_json(:status)
|
|
128
|
+
def feature_status(feature) = read_json(:feature_status, Native.input(feature))
|
|
129
|
+
def reserve(feature, quantity, job_id) = read_json(:reserve, Native.input(feature), integer(quantity), Native.input(job_id))
|
|
130
|
+
def consume(feature, quantity, job_id) = read_json(:consume, Native.input(feature), integer(quantity), Native.input(job_id))
|
|
131
|
+
def commit(reservation_id, job_id) = read_json(:commit, Native.input(reservation_id), Native.input(job_id))
|
|
132
|
+
def cancel(reservation_id, job_id) = read_json(:cancel, Native.input(reservation_id), Native.input(job_id))
|
|
133
|
+
def offline_request(operation = 'activate') = read_json(:offline_request, Native.input(operation))
|
|
134
|
+
def import_offline(request, response) = checked(:import_offline, Native.input(JSON.generate(request)), Native.input(JSON.generate(response)))
|
|
135
|
+
def resolve_metered_feature(job_id, business_succeeded)
|
|
136
|
+
unless business_succeeded == true || business_succeeded == false
|
|
137
|
+
raise ArgumentError, 'Determine the real business outcome; pass true or false'
|
|
138
|
+
end
|
|
139
|
+
checked(:resolve_metered_feature, Native.input(job_id), business_succeeded ? 1 : 0)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def run_feature(feature, &action)
|
|
143
|
+
execution = run_callback(:run_feature, Native.input(feature), &action)
|
|
144
|
+
execution.value
|
|
145
|
+
end
|
|
146
|
+
def run_metered_feature(feature, quantity, job_id, &action)
|
|
147
|
+
run_callback(:run_metered_feature, Native.input(feature), integer(quantity), Native.input(job_id), &action)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def close
|
|
151
|
+
@lock.synchronize do
|
|
152
|
+
raise Error.new('Do not close this client inside its business callback', code: 'WORKFLOW_BUSY') if @in_callback
|
|
153
|
+
if @native
|
|
154
|
+
Native.call(:destroy, @native)
|
|
155
|
+
@native = nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
def integer(value)
|
|
163
|
+
raise ArgumentError, 'Quantity must be a positive signed 64-bit integer' unless value.is_a?(Integer) && value.positive? && value <= 0x7fff_ffff_ffff_ffff
|
|
164
|
+
value
|
|
165
|
+
end
|
|
166
|
+
def active
|
|
167
|
+
raise Error.new('Client is closed', code: 'CLIENT_CLOSED') unless @native
|
|
168
|
+
@native
|
|
169
|
+
end
|
|
170
|
+
def error
|
|
171
|
+
# ln_error_info: int, char[80], char[128], int, int; the shared ABI uses
|
|
172
|
+
# 32-bit ints on supported Windows/Linux/macOS targets.
|
|
173
|
+
info = Fiddle::Pointer.malloc(220, Fiddle::RUBY_FREE)
|
|
174
|
+
info[0,220] = "\0" * 220
|
|
175
|
+
Native.call(:error_info, @native, info)
|
|
176
|
+
Error.new('The licensing operation did not complete',
|
|
177
|
+
code: info[4,80].split("\0", 2).first,
|
|
178
|
+
http_status: info[0,4].unpack1('i'), request_id: info[84,128].split("\0", 2).first,
|
|
179
|
+
retryable: info[212,4].unpack1('i') != 0, retry_after_seconds: info[216,4].unpack1('i'))
|
|
180
|
+
end
|
|
181
|
+
def checked(name, *arguments)
|
|
182
|
+
@lock.synchronize { raise error if Native.call(name, active, *arguments) == 0 }
|
|
183
|
+
true
|
|
184
|
+
end
|
|
185
|
+
def read_json(name, *arguments)
|
|
186
|
+
@lock.synchronize do
|
|
187
|
+
pointer = Native.call(name, active, *arguments)
|
|
188
|
+
raise error if pointer.null?
|
|
189
|
+
Native.owned_json(pointer)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
def run_callback(name, *arguments)
|
|
193
|
+
raise ArgumentError, 'Supply your business operation as a block' unless block_given?
|
|
194
|
+
@lock.synchronize do
|
|
195
|
+
raise Error.new('A business callback is already running', code: 'WORKFLOW_BUSY') if @in_callback
|
|
196
|
+
execution = Execution.new(executed: false)
|
|
197
|
+
business_error = nil
|
|
198
|
+
callback = Fiddle::Closure::BlockCaller.new(Fiddle::TYPE_INT, [Fiddle::TYPE_VOIDP]) do |_context|
|
|
199
|
+
@in_callback = true
|
|
200
|
+
begin
|
|
201
|
+
execution.executed = true
|
|
202
|
+
execution.value = yield
|
|
203
|
+
1
|
|
204
|
+
rescue Exception => exception
|
|
205
|
+
# Exceptions never cross the C boundary. The native workflow settles
|
|
206
|
+
# cancellation (or journals a pending cancellation) before re-raising.
|
|
207
|
+
business_error = exception
|
|
208
|
+
0
|
|
209
|
+
ensure
|
|
210
|
+
@in_callback = false
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
ok = Native.call(name, active, *arguments, callback, 0)
|
|
214
|
+
raise business_error if business_error
|
|
215
|
+
raise error if ok == 0
|
|
216
|
+
execution
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def self.verify_lease(envelope, pins, product, device, now)
|
|
222
|
+
parsed_envelope = Native.call(:json, Native.input(JSON.generate(envelope)))
|
|
223
|
+
parsed_pins = Native.call(:json, Native.input(JSON.generate(pins)))
|
|
224
|
+
begin
|
|
225
|
+
Native.call(:verify, parsed_envelope, parsed_pins, Native.input(product), Native.input(device), Integer(now)) != 0
|
|
226
|
+
ensure
|
|
227
|
+
Native.call(:delete_json, parsed_envelope)
|
|
228
|
+
Native.call(:delete_json, parsed_pins)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* Included by licentivo.c only. Private task IDs/outcome, never business data. */
|
|
2
|
+
static int task_error(ln_client*c,const char*code){lock(c);snprintf(c->error,sizeof(c->error),"%s",code);c->http_status=0;c->retryable=0;c->request_id[0]=0;view_lock(c);int online=c->view_online;view_unlock(c);publish(c,online);unlock(c);return 0;}
|
|
3
|
+
static int task_enter(ln_client*c){view_lock(c);int busy=c->task_busy;if(!busy)c->task_busy=1;view_unlock(c);return !busy;}
|
|
4
|
+
static void task_leave(ln_client*c){view_lock(c);c->task_busy=0;view_unlock(c);}
|
|
5
|
+
static char*task_path(ln_client*c,const char*id){
|
|
6
|
+
if(!id||!id[0]||strlen(id)>80)return NULL;
|
|
7
|
+
for(const unsigned char*p=(const unsigned char*)id;*p;p++)if(*p<33||*p>126)return NULL;
|
|
8
|
+
char source[65],digest[65],input[256];lock(c);
|
|
9
|
+
const char*key=text(c->cfg,"license_key");int ok=key&&key[0]?hash(key,source):text(c->auth,"source_hash")!=NULL;
|
|
10
|
+
if(ok&&(!key||!key[0]))snprintf(source,sizeof(source),"%s",text(c->auth,"source_hash"));unlock(c);
|
|
11
|
+
if(!ok)return NULL;snprintf(input,sizeof(input),"%s\n%s",source,id);if(!hash(input,digest))return NULL;
|
|
12
|
+
const char*cache=text(c->cfg,"cache_path");if(!cache)return NULL;
|
|
13
|
+
char*path=malloc(strlen(cache)+78);if(path)sprintf(path,"%s.task-%s.json",cache,digest);return path;
|
|
14
|
+
}
|
|
15
|
+
static cJSON*task_read(const char*path,const char*id){
|
|
16
|
+
struct stat info;if(stat(path,&info)==0){if(info.st_size>4096)return NULL;char*raw=readfile(path,4096);cJSON*task=raw?ln_json(raw):NULL;free(raw);int64_t version;
|
|
17
|
+
int64_t quantity;
|
|
18
|
+
if(!task||!num(task,"version",&version)||version!=1||!text(task,"operation_id")||strcmp(text(task,"operation_id"),id)||!text(task,"reservation_id")||!text(task,"reservation_id")[0]||!text(task,"state")||!text(task,"feature")||!text(task,"feature")[0]||!num(task,"quantity",&quantity)||quantity<=0){cJSON_Delete(task);return NULL;}return task;
|
|
19
|
+
}
|
|
20
|
+
if(errno!=ENOENT)return NULL;
|
|
21
|
+
cJSON*task=cJSON_CreateObject();cJSON_AddNumberToObject(task,"version",1);cJSON_AddStringToObject(task,"operation_id",id);cJSON_AddStringToObject(task,"reservation_id","");cJSON_AddStringToObject(task,"state","");return task;
|
|
22
|
+
}
|
|
23
|
+
static void task_state(cJSON*task,const char*state){cJSON_ReplaceItemInObjectCaseSensitive(task,"state",cJSON_CreateString(state));}
|
|
24
|
+
static int task_finish(ln_client*c,const char*path,cJSON*task,int succeeded){
|
|
25
|
+
char*raw=succeeded?ln_commit(c,text(task,"reservation_id"),text(task,"operation_id")):ln_cancel(c,text(task,"reservation_id"),text(task,"operation_id"));
|
|
26
|
+
cJSON*receipt=raw?ln_json(raw):NULL;ln_free_string(raw);if(!receipt)return 0;
|
|
27
|
+
const char*state=text(receipt,"status"),*expected=succeeded?"committed":"canceled";
|
|
28
|
+
int ok=state&&!strcmp(state,expected);cJSON_Delete(receipt);
|
|
29
|
+
if(!ok)return task_error(c,"RESERVATION_CLOSED");task_state(task,expected);
|
|
30
|
+
return write_state(path,task)?1:task_error(c,"TASK_JOURNAL_ERROR");
|
|
31
|
+
}
|
|
32
|
+
int ln_run_metered_feature(ln_client*c,const char*feature,int64_t quantity,const char*id,ln_feature_action action,void*context){
|
|
33
|
+
if(!c||!feature||!action||quantity<=0)return c?task_error(c,"TASK_INPUT_INVALID"):0;
|
|
34
|
+
if(!task_enter(c))return task_error(c,"TASK_BUSY");
|
|
35
|
+
int ok=0;char*path=task_path(c,id);cJSON*task=path?task_read(path,id):NULL;
|
|
36
|
+
if(!task){task_error(c,"TASK_JOURNAL_ERROR");goto done;}
|
|
37
|
+
const char*state=text(task,"state");
|
|
38
|
+
if(state[0]){int64_t old_quantity;if(!text(task,"feature")||strcmp(text(task,"feature"),feature)||!num(task,"quantity",&old_quantity)||old_quantity!=quantity){task_error(c,"OPERATION_ID_CONFLICT");goto done;}}
|
|
39
|
+
if(!state[0]){cJSON_AddStringToObject(task,"feature",feature);cJSON_AddNumberToObject(task,"quantity",(double)quantity);}
|
|
40
|
+
if(!strcmp(state,"committed")){ok=1;goto done;}
|
|
41
|
+
if(!strcmp(state,"commit_pending")){ok=task_finish(c,path,task,1);goto done;}
|
|
42
|
+
if(!strcmp(state,"cancel_pending")){if(!task_finish(c,path,task,0))goto done;task_error(c,"OPERATION_CANCELED");goto done;}
|
|
43
|
+
if(!strcmp(state,"canceled")){task_error(c,"OPERATION_CANCELED");goto done;}
|
|
44
|
+
if(!strcmp(state,"business_running")){task_error(c,"BUSINESS_OUTCOME_UNKNOWN");goto done;}
|
|
45
|
+
if(state[0]){task_error(c,"TASK_JOURNAL_ERROR");goto done;}
|
|
46
|
+
if(!ln_check_feature(c,feature)){task_error(c,"FEATURE_NOT_ENTITLED");goto done;}
|
|
47
|
+
char*raw=ln_reserve(c,feature,quantity,id);cJSON*hold=raw?ln_json(raw):NULL;ln_free_string(raw);
|
|
48
|
+
if(!hold)goto done;
|
|
49
|
+
const char*held=text(hold,"status");
|
|
50
|
+
if(held&&!strcmp(held,"committed")){cJSON_ReplaceItemInObjectCaseSensitive(task,"reservation_id",cJSON_CreateString(text(hold,"reservation_id")));task_state(task,"committed");cJSON_Delete(hold);ok=write_state(path,task)?1:task_error(c,"TASK_JOURNAL_ERROR");goto done;}
|
|
51
|
+
if(!held||strcmp(held,"pending")||!text(hold,"reservation_id")){cJSON_Delete(hold);task_error(c,"RESERVATION_CLOSED");goto done;}
|
|
52
|
+
cJSON_ReplaceItemInObjectCaseSensitive(task,"reservation_id",cJSON_CreateString(text(hold,"reservation_id")));cJSON_Delete(hold);task_state(task,"business_running");
|
|
53
|
+
if(!write_state(path,task)){raw=ln_cancel(c,text(task,"reservation_id"),id);ln_free_string(raw);task_error(c,"TASK_JOURNAL_ERROR");goto done;}
|
|
54
|
+
if(!action(context)){
|
|
55
|
+
task_state(task,"cancel_pending");if(write_state(path,task))task_finish(c,path,task,0);task_error(c,"BUSINESS_ACTION_FAILED");goto done;
|
|
56
|
+
}
|
|
57
|
+
task_state(task,"commit_pending");if(!write_state(path,task)){task_error(c,"BUSINESS_COMPLETED_JOURNAL_ERROR");goto done;}
|
|
58
|
+
ok=task_finish(c,path,task,1);
|
|
59
|
+
done:
|
|
60
|
+
cJSON_Delete(task);free(path);task_leave(c);return ok;
|
|
61
|
+
}
|
|
62
|
+
int ln_resolve_metered_feature(ln_client*c,const char*id,int succeeded){
|
|
63
|
+
if(!c)return 0;if(!task_enter(c))return task_error(c,"TASK_BUSY");
|
|
64
|
+
int ok=0;char*path=task_path(c,id);cJSON*task=path?task_read(path,id):NULL;
|
|
65
|
+
if(!task){task_error(c,"TASK_JOURNAL_ERROR");goto done;}
|
|
66
|
+
const char*state=text(task,"state");if(!strcmp(state,succeeded?"committed":"canceled")){ok=1;goto done;}
|
|
67
|
+
if(!text(task,"reservation_id")[0]||(strcmp(state,"business_running")&&strcmp(state,"commit_pending")&&strcmp(state,"cancel_pending"))||(!succeeded&&!strcmp(state,"commit_pending"))||(succeeded&&!strcmp(state,"cancel_pending"))){task_error(c,"TASK_OUTCOME_CONFLICT");goto done;}
|
|
68
|
+
task_state(task,succeeded?"commit_pending":"cancel_pending");if(!write_state(path,task)){task_error(c,"TASK_JOURNAL_ERROR");goto done;}
|
|
69
|
+
ok=task_finish(c,path,task,succeeded);
|
|
70
|
+
done:
|
|
71
|
+
cJSON_Delete(task);free(path);task_leave(c);return ok;
|
|
72
|
+
}
|