From 243ed398a0a3ac73b8b691ad6f635afa15dd335b Mon Sep 17 00:00:00 2001 From: Alexander Olofsson Date: Mon, 23 Feb 2026 18:37:34 +0100 Subject: [PATCH] Initial commit --- .gitignore | 17 +++ .gitlab-ci.yml | 10 ++ .rspec | 3 + .rubocop.yml | 8 ++ Containerfile | 16 +++ Gemfile | 6 + LICENSE.txt | 21 +++ README.md | 16 +++ Rakefile | 12 ++ config.ru | 19 +++ config.yml.example | 8 ++ infisical_license_server.gemspec | 46 +++++++ lib/infisical_license_server.rb | 20 +++ lib/infisical_license_server/api/auth.rb | 38 ++++++ lib/infisical_license_server/api/license.rb | 44 +++++++ .../api/license_server.rb | 54 ++++++++ lib/infisical_license_server/config.rb | 121 ++++++++++++++++++ lib/infisical_license_server/server.rb | 18 +++ lib/infisical_license_server/util.rb | 8 ++ lib/infisical_license_server/version.rb | 5 + spec/infisical_license_server_spec.rb | 7 + spec/spec_helper.rb | 15 +++ 22 files changed, 512 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 Containerfile create mode 100644 Gemfile create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 Rakefile create mode 100644 config.ru create mode 100644 config.yml.example create mode 100644 infisical_license_server.gemspec create mode 100644 lib/infisical_license_server.rb create mode 100644 lib/infisical_license_server/api/auth.rb create mode 100644 lib/infisical_license_server/api/license.rb create mode 100644 lib/infisical_license_server/api/license_server.rb create mode 100644 lib/infisical_license_server/config.rb create mode 100644 lib/infisical_license_server/server.rb create mode 100644 lib/infisical_license_server/util.rb create mode 100644 lib/infisical_license_server/version.rb create mode 100644 spec/infisical_license_server_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c0a512 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +/vendor/ + +# rspec failure tracking +.rspec_status + +# install data +/Gemfile.lock +/config.yml + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..43cf2ec --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,10 @@ +default: + image: ruby:3.2.9 + + before_script: + - gem install bundler -v 2.6.9 + - bundle install + +example_job: + script: + - bundle exec rake diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..537f3da --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +AllCops: + TargetRubyVersion: 3.1 + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..c71c654 --- /dev/null +++ b/Containerfile @@ -0,0 +1,16 @@ +FROM docker.io/library/ruby + +ENV APP_HOME /app +ENV RACK_ENV production +WORKDIR /app + +ADD Gemfile /app/ +ADD *gemspec /app/ +ADD config.ru /app/ +ADD lib /app/lib/ + +RUN bundle config set without 'development' && \ + bundle install + +EXPOSE 9292/tcp +CMD ["bundle", "exec", "rackup"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..d550875 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in infisical_license_server.gemspec +gemspec diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..4437e05 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026 Alexander Olofsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..19c7741 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +Infisical License Server +======================== + +A simple implementation of the Infisical license server API, which supports on-prem online licenses. + +## Usage + +Create a `config.yml` file following the example in [`config.yml.example`](config.yml.example), then run the application using `rackup`. + +The configuration values that need to be set in Infisical to use the server are; +- `LICENSE_KEY=<.>` - should be set to the `key` value for the plan you want to use +- `LICENSE_SERVER_URL=<.>` - should be set to the URL where this server is running + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..cca7175 --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "rubocop/rake_task" + +RuboCop::RakeTask.new + +task default: %i[spec rubocop] diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..dec1b5d --- /dev/null +++ b/config.ru @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_relative 'lib/infisical_license_server' + +# Ensure config can be loaded +InfisicalLicenseServer.config + +map '/api/auth/v1' do + run InfisicalLicenseServer::API::AuthV1 +end +map '/api/license/v1' do + run InfisicalLicenseServer::API::LicenseV1 +end +map '/api/license-server/v1' do + run InfisicalLicenseServer::API::LicenseServerV1 +end +map '/' do + run InfisicalLicenseServer::Server +end diff --git a/config.yml.example b/config.yml.example new file mode 100644 index 0000000..52ba15f --- /dev/null +++ b/config.yml.example @@ -0,0 +1,8 @@ +--- +plans: + 'Some plan name': + key: 'Some API KEY' + # https://github.com/Infisical/infisical/blob/main/backend/src/ee/services/license/license-types.ts#L34 + features: + identityLimit: 10 + samlSSO: true diff --git a/infisical_license_server.gemspec b/infisical_license_server.gemspec new file mode 100644 index 0000000..34a38fc --- /dev/null +++ b/infisical_license_server.gemspec @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "lib/infisical_license_server/version" + +Gem::Specification.new do |spec| + spec.name = "infisical_license_server" + spec.version = InfisicalLicenseServer::VERSION + spec.authors = ["Alexander Olofsson"] + spec.email = ["me@ananace.dev"] + + spec.summary = "TODO: Write a short summary, because RubyGems requires one." + spec.description = "TODO: Write a longer description or delete this line." + spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.license = "MIT" + spec.required_ruby_version = ">= 3.1.0" + + spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." + spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + gemspec = File.basename(__FILE__) + spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| + ls.readlines("\x0", chomp: true).reject do |f| + (f == gemspec) || + f.start_with?(*%w[bin/ test/ spec/ features/ .git .gitlab-ci.yml appveyor Gemfile]) + end + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + # For more information and examples about making a new gem, check out our + # guide at: https://bundler.io/guides/creating_gem.html + spec.add_dependency 'rackup' + spec.add_dependency 'sinatra' + spec.add_dependency 'webrick' + + spec.add_development_dependency 'rake' + spec.add_development_dependency 'rspec' + spec.add_development_dependency 'rubocop' + spec.add_development_dependency 'sinatra-contrib' +end diff --git a/lib/infisical_license_server.rb b/lib/infisical_license_server.rb new file mode 100644 index 0000000..2808d09 --- /dev/null +++ b/lib/infisical_license_server.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative "infisical_license_server/version" + +module InfisicalLicenseServer + class Error < StandardError; end + + module API + autoload :AuthV1, 'infisical_license_server/api/auth' + autoload :LicenseV1, 'infisical_license_server/api/license' + autoload :LicenseServerV1, 'infisical_license_server/api/license_server' + end + + autoload :Config, 'infisical_license_server/config' + autoload :Server, 'infisical_license_server/server' + + def self.config + @config ||= InfisicalLicenseServer::Config.new + end +end diff --git a/lib/infisical_license_server/api/auth.rb b/lib/infisical_license_server/api/auth.rb new file mode 100644 index 0000000..4c4389f --- /dev/null +++ b/lib/infisical_license_server/api/auth.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'base64' +require 'sinatra/base' + +module InfisicalLicenseServer::API + class AuthV1 < Sinatra::Base + configure :development do + require 'sinatra/reloader' + register Sinatra::Reloader + end + + enable :logging + + post '/license-login' do + key = request.get_header('HTTP_X_API_KEY') + region = request.get_header('HTTP_X_REGION') + + error 400, 'Missing API key' unless key + + plan = InfisicalLicenseServer.config.plan_for key + error 401 unless plan + + region = " (region #{region})" if region + logger.info "New auth for #{plan['plan']}#{region}" + + { + token: Base64.strict_encode64( + { + auth: Time.now.to_i, + key: plan['key'], + plan: plan['plan'] + }.to_json + ) + }.to_json + end + end +end diff --git a/lib/infisical_license_server/api/license.rb b/lib/infisical_license_server/api/license.rb new file mode 100644 index 0000000..aa93128 --- /dev/null +++ b/lib/infisical_license_server/api/license.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module InfisicalLicenseServer::API + class LicenseV1 < Sinatra::Base + configure :development do + require 'sinatra/reloader' + register Sinatra::Reloader + end + + enable :logging + + get '/plan' do + auth = request.get_header('HTTP_AUTHORIZATION') + error 401 unless InfisicalLicenseServer.config.auth? auth + + plan = InfisicalLicenseServer.config.plan_for(auth) + + { + currentPlan: plan['merged'].compact + }.to_json + end + + patch '/license' do + auth = request.get_header('HTTP_AUTHORIZATION') + error 401 unless InfisicalLicenseServer.config.auth? auth + + plan = InfisicalLicenseServer.config.plan_for(auth) + error 500 unless plan + + #request.body.rewind + data = JSON.parse(request.body.read) + + logger.info "Plan #{plan['plan']} uses #{data['usedSeats']} seats and #{data['usedIdentitySeats']} identities" + + # Cache these for next plan request + plan['features']['membersUsed'] = data['usedSeats'] + plan['features']['identitiesUsed'] = data['usedIdentitySeats'] + + nil + end + end +end diff --git a/lib/infisical_license_server/api/license_server.rb b/lib/infisical_license_server/api/license_server.rb new file mode 100644 index 0000000..f036b87 --- /dev/null +++ b/lib/infisical_license_server/api/license_server.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module InfisicalLicenseServer::API + class LicenseServerV1 < Sinatra::Base + # https://github.com/Infisical/infisical/blob/main/backend/src/ee/services/license/licence-enums.ts + HEADERS = %w[Allowed Used].freeze + ROWS = [ + { name: "Organization identity limit", field: "identityLimit" }, + { name: "Project limit", field: "workspaceLimit" }, + { name: "Environment limit", field: "environmentLimit" }, + { name: "Secret versioning", field: "secretVersioning" }, + { name: "Point in time recovery", field: "pitRecovery" }, + { name: "RBAC", field: "rbac" }, + { name: "Custom rate limits", field: "customRateLimits" }, + { name: "Custom alerts", field: "customAlerts" }, + { name: "Audit logs", field: "auditLogs" }, + { name: "SAML SSO", field: "samlSSO" }, + { name: "SSH Host Groups", field: "sshHostGroups" }, + { name: "Hardware Security Module (HSM)", field: "hsm" }, + { name: "OIDC SSO", field: "oidcSSO" }, + { name: "Secret approvals", field: "secretApproval" }, + { name: "Secret rotation", field: "secretRotation" }, + { name: "Instance User Management", field: "instanceUserManagement" }, + { name: "External KMS", field: "externalKms" } + ].freeze + + configure :development do + require 'sinatra/reloader' + register Sinatra::Reloader + end + + enable :logging + + get '/customers/on-prem-plan/table' do + auth = request.get_header('HTTP_AUTHORIZATION') + error 401 unless InfisicalLicenseServer.config.auth? auth + + plan = InfisicalLicenseServer.config.plan_for(auth) + error 500 unless plan + + { + head: HEADERS.map { |head| { name: head } }, + rows: ROWS.map do |row| + { + name: row[:name], + allowed: row[:field].end_with?('Limit') ? true : (plan['merged'][row[:field]] || false), + } + end + }.to_json + end + end +end diff --git a/lib/infisical_license_server/config.rb b/lib/infisical_license_server/config.rb new file mode 100644 index 0000000..0c8ad36 --- /dev/null +++ b/lib/infisical_license_server/config.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'psych' + +require_relative 'util' + +class InfisicalLicenseServer::Config + AUTH_TIME = 1 * 24 * 60 * 60 + + attr_reader :data + + def initialize(file = 'config.yml') + raise 'Missing configuration file' unless File.exist?(file) + + @data = Psych.load_file(file) + end + + def auth?(token) + Time.now - Time.at(decode_auth(token)['auth']) <= AUTH_TIME + rescue StandardError => e + puts "Failed to read auth token - #{e.class}: #{e}" + puts e.backtrace + false + end + + def valid_key?(key) + data['plans'].any? { |_, plan| plan['key'] == key } + end + + def plan_for(key) + if key.start_with? 'Bearer ' + key = decode_auth(key)['key'] + end + + plan_name, plan = data['plans'].find { |_, search| search['key'] == key } + return nil unless plan + + plan['merged'] = default_plan.merge(_id: plan_name, slug: 'enterprise').deep_merge(plan['features']) + plan['plan'] = plan_name + plan + end + + def decode_auth(token) + raise 'Missing auth' unless token + + token = token[7..] if token.start_with? 'Bearer ' + + JSON.parse(Base64.decode64(token)) + end + + # https://github.com/Infisical/infisical/blob/main/backend/src/ee/services/license/license-fns.ts#L55 + def self.default_plan + { + _id: nil, + slug: nil, + tier: -1, + workspaceLimit: nil, + workspacesUsed: 0, + memberLimit: nil, + membersUsed: 0, + environmentLimit: nil, + environmentsUsed: 0, + identityLimit: nil, + identitiesUsed: 0, + dynamicSecret: false, + secretVersioning: true, + pitRecovery: false, + ipAllowlisting: false, + rbac: false, + githubOrgSync: false, + customRateLimits: false, + subOrganization: false, + customAlerts: false, + secretAccessInsights: false, + auditLogs: false, + auditLogsRetentionDays: 0, + auditLogStreams: false, + auditLogStreamLimit: 3, + samlSSO: false, + enforceGoogleSSO: false, + hsm: false, + oidcSSO: false, + scim: false, + ldap: false, + groups: false, + status: nil, + trial_end: nil, + has_used_trial: true, + secretApproval: false, + secretRotation: false, + caCrl: false, + instanceUserManagement: false, + externalKms: false, + rateLimits: { + readLimit: 60, + writeLimit: 200, + secretsLimit: 40 + }, + pkiEst: false, + pkiAcme: false, + enforceMfa: false, + projectTemplates: false, + kmip: false, + gateway: false, + sshHostGroups: false, + secretScanning: false, + enterpriseSecretSyncs: false, + enterpriseCertificateSyncs: false, + enterpriseAppConnections: false, + fips: false, + eventSubscriptions: false, + machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false, + secretShareExternalBranding: false + } + end + + def default_plan + self.class.default_plan + end +end diff --git a/lib/infisical_license_server/server.rb b/lib/infisical_license_server/server.rb new file mode 100644 index 0000000..fb5273f --- /dev/null +++ b/lib/infisical_license_server/server.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module InfisicalLicenseServer + class Server < Sinatra::Base + configure :development do + require 'sinatra/reloader' + register Sinatra::Reloader + + enable :logging + end + + get '/healthz' do + 'Ok' + end + end +end diff --git a/lib/infisical_license_server/util.rb b/lib/infisical_license_server/util.rb new file mode 100644 index 0000000..56f7973 --- /dev/null +++ b/lib/infisical_license_server/util.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ::Hash + def deep_merge(second) + merger = proc { |key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 } + self.merge(second, &merger) + end +end diff --git a/lib/infisical_license_server/version.rb b/lib/infisical_license_server/version.rb new file mode 100644 index 0000000..ab1da6b --- /dev/null +++ b/lib/infisical_license_server/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module InfisicalLicenseServer + VERSION = "0.1.0" +end diff --git a/spec/infisical_license_server_spec.rb b/spec/infisical_license_server_spec.rb new file mode 100644 index 0000000..b78acb8 --- /dev/null +++ b/spec/infisical_license_server_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe InfisicalLicenseServer do + it "has a version number" do + expect(InfisicalLicenseServer::VERSION).not_to be nil + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..989d3bf --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "infisical_license_server" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end