commit 243ed398a0a3ac73b8b691ad6f635afa15dd335b Author: Alexander Olofsson Date: Mon Feb 23 18:37:34 2026 +0100 Initial commit 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