Initial commit

This commit is contained in:
Alexander Olofsson 2026-02-23 18:37:34 +01:00
commit 243ed398a0
Signed by: ace
GPG key ID: D439C9470CB04C73
22 changed files with 512 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -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

10
.gitlab-ci.yml Normal file
View file

@ -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

3
.rspec Normal file
View file

@ -0,0 +1,3 @@
--format documentation
--color
--require spec_helper

8
.rubocop.yml Normal file
View file

@ -0,0 +1,8 @@
AllCops:
TargetRubyVersion: 3.1
Style/StringLiterals:
EnforcedStyle: double_quotes
Style/StringLiteralsInInterpolation:
EnforcedStyle: double_quotes

16
Containerfile Normal file
View file

@ -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"]

6
Gemfile Normal file
View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
source "https://rubygems.org"
# Specify your gem's dependencies in infisical_license_server.gemspec
gemspec

21
LICENSE.txt Normal file
View file

@ -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.

16
README.md Normal file
View file

@ -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).

12
Rakefile Normal file
View file

@ -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]

19
config.ru Normal file
View file

@ -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

8
config.yml.example Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
module InfisicalLicenseServer
VERSION = "0.1.0"
end

View file

@ -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

15
spec/spec_helper.rb Normal file
View file

@ -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