Clortho is an API server for managing license keys and subscriptions. It handles license generation, validation, and offline verification.
- License Management: Generate, validate, update, and revoke license keys.
- Flexible Licensing: Support for Perpetual, Timed, and Trial licenses.
- Feature & Release Control: Restrict licenses to specific product features or software releases. Features and releases can be scoped to a product, a product group, or defined globally.
- Product Management: Organize licenses by products, releases, and features.
- Product Groups: Bundle products together with shared settings.
- Configurable Separators: Customize the separator between prefix and key per product (e.g.,
-,_, or#). - Offline Verification: Option to include signed JWT tokens in license check responses for offline validation.
- Secure: JWT Authentication for management endpoints, bcrypt password hashing.
- Rate Limiting: Protects against abuse with configurable IP-based rate limiting.
- IP Restrictions: Restrict licenses to specific IP addresses or CIDR networks.
- Auto Allowed IPs: Automatically add client IPs to the allowlist during validation up to a configurable limit.
- Response Signing: Ed25519 signatures for resources (e.g. valid: true/false) for offline verification.
- Resource Ownership: Optional
owner_idfield on all resources (Products, Licenses, etc.) to support multi-tenancy and filtering.
- Language: Go (Golang) 1.25+
- Framework: Gin Web Framework
- Database: PostgreSQL
- Driver: pgx (with connection pooling)
- Migrations: golang-migrate
- Logging:
log/slog(structured logging)
clortho/
├── cmd/
│ └── server/
│ └── main.go # Application entry point
├── internal/
│ ├── api/
│ │ ├── handlers/ # HTTP handlers (organized by domain)
│ │ │ ├── feature_handlers.go
│ │ │ ├── license_handlers.go
│ │ │ ├── log_handlers.go
│ │ │ ├── product_group_handlers.go
│ │ │ ├── product_handlers.go
│ │ │ ├── release_handlers.go
│ │ │ ├── stats_handlers.go
│ │ │ └── utils.go
│ │ ├── middleware/ # JWT auth, rate limiting, response signing
│ │ │ ├── auth.go
│ │ │ ├── rate_limit.go
│ │ │ └── signature.go
│ │ └── server.go # Server setup and routing
│ ├── config/ # Configuration loading
│ ├── database/ # Database connection and migrations
│ ├── models/ # Data models
│ │ ├── models.go
│ │ └── pagination.go
│ ├── service/ # Business logic
│ │ ├── license_generator.go
│ │ ├── logging.go
│ │ └── signature.go
│ └── store/ # Data access layer
│ ├── errors.go # Custom error types
│ ├── feature_store.go
│ ├── license_store.go
│ ├── log_store.go
│ ├── product_group_store.go
│ ├── product_store.go
│ ├── release_store.go
│ └── stats_store.go
├── migrations/ # Database migrations
├── scripts/ # Utility scripts
│ ├── generate_keys.go
│ ├── generate_token.go
│ ├── migrate.go
│ └── verify_token.go
├── config.yaml # Configuration file
└── README.md
- Go 1.25 or higher
- PostgreSQL database
Clortho is configured using a config.yaml file in the root directory.
Example config.yaml:
port: "8080"
database_url: "postgres://user:password@localhost:5432/clortho?sslmode=disable"
admin_secret: "your-super-secret-key"
rate_limit:
requests_per_second: 5
burst: 10
enabled: true
response_signing_private_key: "BASE64_ENCODED_ED25519_PRIVATE_KEY"The scripts/ directory contains useful utilities:
Generates Ed25519 keys. You must add the output to your config.yaml or set them as environment variables.
go run scripts/generate_keys.goGenerates a JWT token for accessing protected admin endpoints.
go run scripts/generate_token.goVerifies the self-contained JWT token from the API response.
go run scripts/verify_token.go -pubkey "YOUR_PUBLIC_KEY" -token "JWT_TOKEN_FROM_RESPONSE"Run database migrations using the provided script or Makefile.
Development (Local):
# Using Makefile (defaults to reading config.yaml)
make migrate-up
make migrate-down
# Using script directly
go run scripts/migrate.go -direction upProduction (Docker):
The docker image includes a compiled migration binary clortho-migrate.
# Run migration using the container
docker run --rm \
-v $(pwd)/config.yaml:/app/config.yaml \
clortho ./clortho-migrate -direction up
# Or if using docker compose
docker compose run --rm clortho ./clortho-migrate -direction up-
Clone the repository
git clone https://github.com/KubiqIO/clortho.git cd clortho -
Setup Configuration Create a
config.yamlfile based on the example above. -
Setup Database Ensure your PostgreSQL database is running.
-
Run Tests
make test -
Build and Run the Server
make build ./clortho-server
-
Docker Support You can also run Clortho using Docker.
Build the image:
docker build -t clortho .Run the container:
docker run -p 8080:8080 \ -v $(pwd)/config.yaml:/app/config.yaml \ clorthoNote: Ensure your
config.yamlpoints to a database accessible from the container (e.g., usehost.docker.internalinstead oflocalhoston some systems, or use a Docker network).Docker Compose: To run the full stack (App + PostgreSQL):
docker compose up --build
Token Generation: The docker image includes a pre-compiled binary to generate admin tokens.
docker run --rm clortho ./generate-token # or if using docker compose docker compose exec app ./generate-token
All admin endpoints require a JWT token. The token can be generated using the scripts/generate_token.go script. The token should be included in the Authorization header as a Bearer token.
To revoke all admin tokens you can change the admin_secret in the config.yaml file and then restart the server.
Clortho signs all API responses using Ed25519 if a response_signing_private_key is configured.
Applications can verify the authenticity of the response using the corresponding public key.
Endpoint: GET /check
Headers:
X-License-Key: The license key to validate (required)
Query Parameters (optional):
| Parameter | Description |
|---|---|
version |
Validate if license is authorized for this release version |
feature |
Validate if license has this feature code enabled |
Examples:
# Basic license check
curl -H "X-License-Key: DEMO-aBc123..." http://localhost:8080/check
# Check if license is valid for version 2.0.0
curl -H "X-License-Key: DEMO-aBc123..." "http://localhost:8080/check?version=2.0.0"
# Check if license has SSO feature enabled
curl -H "X-License-Key: DEMO-aBc123..." "http://localhost:8080/check?feature=sso"Response:
{
"expires_at": "2024-12-31T23:59:59Z",
"reason": "License not valid for version 2.0.0",
"token": "eyJhbG...",
"valid": false
}Note
- The
reasonfield is only present whenvalidisfalse - If a license has no release restrictions, all versions are allowed
- Features must be explicitly enabled on the license to pass validation
Auto Allowed IPs:
If a license has auto_allowed_ip enabled and the current client IP is not in the allowed list:
- The server checks if the number of currently allowed IPs is less than
auto_allowed_ip_limit. - If below the limit, the IP is automatically added to the license's
allowed_ipslist. - Validation proceeds as successful (assuming other checks pass).
- If the limit is reached, validation fails with "IP address not allowed".
Auth: Bearer Token (JWT) required.
| Method | Endpoint | Description | Body |
|---|---|---|---|
| GET | /admin/keys |
Get license | - |
| POST | /admin/keys |
Create license | See below |
| PUT | /admin/keys |
Update license | See below |
| DELETE | /admin/keys |
Revoke license (Soft Delete) | - |
| DELETE | /admin/keys/purge |
Delete license (Hard Delete) | - |
Filtering: List endpoints (GET) support filtering by owner_id query parameter.
GET /admin/keys?owner_id=<UUID>GET /admin/products?owner_id=<UUID>GET /admin/product-groups?owner_id=<UUID>GET /admin/product-groups?owner_id=<UUID>GET /admin/features?product_id=<UUID>GET /admin/features?product_group_id=<UUID>GET /admin/releases?product_id=<UUID>GET /admin/releases?product_group_id=<UUID>GET /admin/features?owner_id=<UUID>GET /admin/releases?owner_id=<UUID>
Endpoint: POST /admin/keys
curl -X POST http://localhost:8080/admin/keys \
-H "Authorization: Bearer <YOUR_JWT_TOKEN>" \
-H "X-License-Key: <YOUR_LICENSE_KEY>" \
-H "Content-Type: application/json" \
-d '{
"product_id": "YOUR_PRODUCT_UUID",
"type": "timed",
"prefix": "PRO",
"length": 25,
"duration": "1y",
"feature_codes": ["sso", "premium"],
"release_versions": ["1.0.0", "2.0.0"],
"allowed_ips": ["192.168.1.10"],
"allowed_networks": ["10.0.0.0/24"],
"auto_allowed_ip": true,
"auto_allowed_ip_limit": 5
}'Duration formats: 5m (minutes), 1h (hours), 1d (days), 2w (weeks), 3mo (months), 1y (years)
Endpoint: PUT /admin/keys/:key
curl -X PUT http://localhost:8080/admin/keys \
-H "Authorization: Bearer <YOUR_JWT_TOKEN>" \
-H "X-License-Key: <YOUR_LICENSE_KEY>" \
-H "Content-Type: application/json" \
-d '{
"type": "perpetual",
"expires_at": null,
"feature_codes": ["sso", "premium"],
"status": "active"
}'Endpoint: DELETE /admin/keys/:key
Revoking a license sets its status to revoked. The license remains in the database but will fail validation checks.
curl -X DELETE http://localhost:8080/admin/keys \
-H "Authorization: Bearer <YOUR_JWT_TOKEN>" \
-H "X-License-Key: <YOUR_LICENSE_KEY>"Endpoint: DELETE /admin/keys/purge
Permanently removes the license from the database.
curl -X DELETE http://localhost:8080/admin/keys/purge \
-H "Authorization: Bearer <YOUR_JWT_TOKEN>" \
-H "X-License-Key: <YOUR_LICENSE_KEY>"| Method | Endpoint | Description | Body / Query |
|---|---|---|---|
| GET | /admin/products |
List products | - |
| GET | /admin/products/:id |
Get product | Optional: ?include=group |
| POST | /admin/products |
Create product | {"name": "...", "license_prefix": "PROD", "license_separator": "_", "license_length": 25, "auto_allowed_ip": true, "auto_allowed_ip_limit": 5, "product_group_id": "YOUR_PRODUCT_GROUP_UUID"} |
| PUT | /admin/products/:id |
Update product | Same as create |
| DELETE | /admin/products/:id |
Delete product | - |
| Method | Endpoint | Description | Body |
|---|---|---|---|
| GET | /admin/product-groups |
List groups | - |
| GET | /admin/product-groups/:id |
Get group | - |
| POST | /admin/product-groups |
Create group | {"name": "Suite", "license_prefix": "SUITE", "license_separator": "_", "license_length": 25, "auto_allowed_ip": true, "auto_allowed_ip_limit": 10} |
| PUT | /admin/product-groups/:id |
Update group | Same as create |
| DELETE | /admin/product-groups/:id |
Delete group | - |
Settings Inheritance
Products can belong to a Product Group via the product_group_id field. When a product belongs to a group, it inherits the following settings if they are not explicitly set on the product:
| Setting | Inheritance Behavior |
|---|---|
license_prefix |
Uses group's prefix if product's is empty |
license_separator |
Uses group's separator if product's is empty/default (-) |
license_length |
Uses group's length if product's is empty |
license_charset |
Uses group's charset if product's is empty |
auto_allowed_ip |
Uses group's setting if product's is false (and group's is true) |
auto_allowed_ip_limit |
Uses group's limit if product's is 0 |
Example:
- Create a Product Group with
license_prefix: "SUITE"andlicense_separator: "_" - Create a Product with
product_group_idpointing to that group, leavinglicense_prefixempty - When generating a license for that product, the key will be
SUITE_abc123...
This allows you to define common settings once at the group level and have all products in that group automatically use them, while still allowing individual products to override with their own values.
Feature & Release Inheritance
Features and Releases can also be defined at the Product Group level. When creating or updating a license for a Product that belongs to a Group:
- You can assign Features that belong to the Product OR the Product Group.
- You can assign Releases that belong to the Product OR the Product Group.
This is useful for shared features (e.g., "SSO", "Audit Logging") or releasing a suite of products together under a common version number.
Global Features & Releases
Features and Releases can also be defined globally (independent of any Product or Group). These are available for assignment to ANY license regardless of its product association.
| Method | Endpoint | Description | Body / Query |
|---|---|---|---|
| GET | /admin/features |
List features | Optional: ?product_id=..., ?product_group_id=..., ?owner_id=... |
| GET | /admin/features/global |
List global features | Optional: ?owner_id=... |
| GET | /admin/features/:id |
Get single feature | - |
| POST | /admin/features |
Create feature | {"name": "...", "code": "...", "product_id": "...", "product_group_id": "..."} |
| PUT | /admin/features/:featureId |
Update feature | {"name": "...", "code": "..."} |
| DELETE | /admin/features/:featureId |
Delete feature | - |
| Method | Endpoint | Description | Body / Query |
|---|---|---|---|
| GET | /admin/releases |
List releases | Optional: ?product_id=..., ?product_group_id=..., ?owner_id=... |
| GET | /admin/releases/global |
List global releases | Optional: ?owner_id=... |
| GET | /admin/releases/:id |
Get single release | - |
| POST | /admin/releases |
Create release | {"version": "...", "product_id": "...", "product_group_id": "..."} |
| PUT | /admin/releases/:releaseId |
Update release | {"version": "..."} |
| DELETE | /admin/releases/:releaseId |
Delete release | - |
| Method | Endpoint | Description |
|---|---|---|
| GET | /admin/logs/license-checks |
Fetch license check logs |
| GET | /admin/logs/admin-actions |
Fetch admin logs |
Endpoint: GET /admin/logs/license-checks
Query Parameters (one required):
license_key: Filter by specific license keyproduct_id: Filter by product UUIDproduct_group_id: Filter by product group UUID
Response: List of log entries containing:
license_keyip_addressuser_agentstatus_code(e.g., 200 for valid, 403 for invalid)request_payload(features requested, version, etc.)response_payload(validation result)created_at
Endpoint: GET /admin/logs/admin-actions
Query Parameters:
actor: Filter by the admin user (optional)
Response: List of log entries containing:
action(e.g.,CREATE_PRODUCT,UPDATE_LICENSE)entity_type(e.g.,product,license)entity_idactordetails(JSON object with specific changes or request data)created_at
