feat(certificates): inject commercial license root certificates into build process
- Added steps in both Linux and macOS workflows to inject commercial license root certificates from GitHub Secrets during the build process. - Updated the `inspect` and `load` packages to include license information, enhancing the application's licensing capabilities. - Included license metadata in the OpenAPI response for better visibility of licensing status.
This commit is contained in:
parent
c89570f3a7
commit
789cc85996
15 changed files with 1318 additions and 0 deletions
13
.github/workflows/release-linux.yml
vendored
13
.github/workflows/release-linux.yml
vendored
|
|
@ -35,6 +35,9 @@ jobs:
|
|||
aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
ROOT_CA_1: ${{ secrets.YAO_ROOT_CA_1_PEM }}
|
||||
ROOT_CA_2: ${{ secrets.YAO_ROOT_CA_2_PEM }}
|
||||
run: |
|
||||
export PATH=$PATH:/github/home/go/bin
|
||||
|
||||
|
|
@ -48,6 +51,16 @@ jobs:
|
|||
git clone https://github.com/yaoapp/yao-init.git /app/yao-init
|
||||
git clone https://github.com/yaoapp/yao.git /app/yao
|
||||
|
||||
# Inject commercial license root certificates from GitHub Secrets
|
||||
if [ -n "$ROOT_CA_1" ]; then
|
||||
echo "$ROOT_CA_1" | base64 -d > /app/yao/commercial/roots/root-ca-1.pem
|
||||
echo "Injected root-ca-1.pem"
|
||||
fi
|
||||
if [ -n "$ROOT_CA_2" ]; then
|
||||
echo "$ROOT_CA_2" | base64 -d > /app/yao/commercial/roots/root-ca-2.pem
|
||||
echo "Injected root-ca-2.pem"
|
||||
fi
|
||||
|
||||
# Extract libv8
|
||||
files=$(find /app/v8go -name "libv8*.zip")
|
||||
for file in $files; do
|
||||
|
|
|
|||
14
.github/workflows/release-macos.yml
vendored
14
.github/workflows/release-macos.yml
vendored
|
|
@ -105,6 +105,20 @@ jobs:
|
|||
rm -f share/const.go.bak
|
||||
grep 'const VERSION' share/const.go
|
||||
|
||||
- name: Inject License Root Certificates
|
||||
env:
|
||||
ROOT_CA_1: ${{ secrets.YAO_ROOT_CA_1_PEM }}
|
||||
ROOT_CA_2: ${{ secrets.YAO_ROOT_CA_2_PEM }}
|
||||
run: |
|
||||
if [ -n "$ROOT_CA_1" ]; then
|
||||
echo "$ROOT_CA_1" | base64 -d > commercial/roots/root-ca-1.pem
|
||||
echo "Injected root-ca-1.pem"
|
||||
fi
|
||||
if [ -n "$ROOT_CA_2" ]; then
|
||||
echo "$ROOT_CA_2" | base64 -d > commercial/roots/root-ca-2.pem
|
||||
echo "Injected root-ca-2.pem"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
"github.com/yaoapp/kun/utils"
|
||||
"github.com/yaoapp/yao/commercial"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/engine"
|
||||
"github.com/yaoapp/yao/share"
|
||||
|
|
@ -20,6 +21,7 @@ var inspectCmd = &cobra.Command{
|
|||
"version": share.VERSION,
|
||||
"config": config.Conf,
|
||||
}
|
||||
res["license"] = commercial.License
|
||||
if share.Tools != nil {
|
||||
res["tools"] = share.Tools
|
||||
}
|
||||
|
|
|
|||
121
commercial/cmd/generate-test-cert/main.go
Normal file
121
commercial/cmd/generate-test-cert/main.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Command generate-test-cert creates a test root CA and a license certificate
|
||||
// for local development and testing.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./commercial/cmd/generate-test-cert \
|
||||
// -out-cert /path/to/yao-dev-app/license.pem \
|
||||
// -out-root-ca ./commercial/roots/root-ca-1.pem
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/commercial"
|
||||
)
|
||||
|
||||
func main() {
|
||||
outCert := flag.String("out-cert", "license.pem", "path to write the license certificate PEM")
|
||||
outRootCA := flag.String("out-root-ca", "", "path to write the root CA PEM (optional; for injecting into commercial/roots/)")
|
||||
flag.Parse()
|
||||
|
||||
rootKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "generate root key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rootSerial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
rootTmpl := &x509.Certificate{
|
||||
SerialNumber: rootSerial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Yao Test Root CA",
|
||||
Organization: []string{"Infinite Wisdom Software"},
|
||||
Country: []string{"CN"},
|
||||
},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 1,
|
||||
}
|
||||
|
||||
rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootKey.PublicKey, rootKey)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "create root cert: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rootCert, _ := x509.ParseCertificate(rootDER)
|
||||
rootPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER})
|
||||
|
||||
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "generate leaf key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
leafTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(20001),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Yao Dev App",
|
||||
Organization: []string{"Dev Testing"},
|
||||
Country: []string{"CN"},
|
||||
},
|
||||
EmailAddresses: []string{"dev@yaoapps.com"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
ExtraExtensions: []pkix.Extension{
|
||||
{Id: commercial.OIDProduct, Value: []byte("yao,tai")},
|
||||
{Id: commercial.OIDEdition, Value: []byte("enterprise")},
|
||||
{Id: commercial.OIDMaxUsers, Value: []byte("0")},
|
||||
{Id: commercial.OIDMaxTaiNodes, Value: []byte("0")},
|
||||
{Id: commercial.OIDMaxAgents, Value: []byte("0")},
|
||||
{Id: commercial.OIDMaxSandboxes, Value: []byte("0")},
|
||||
{Id: commercial.OIDMaxAPIRPM, Value: []byte("0")},
|
||||
{Id: commercial.OIDMaxStorageGB, Value: []byte("0")},
|
||||
{Id: commercial.OIDAllowBrandingRemoval, Value: []byte("true")},
|
||||
{Id: commercial.OIDAllowWhiteLabel, Value: []byte("true")},
|
||||
{Id: commercial.OIDAllowMultiTenant, Value: []byte("true")},
|
||||
{Id: commercial.OIDAllowCustomDomain, Value: []byte("true")},
|
||||
{Id: commercial.OIDAllowHostExec, Value: []byte("true")},
|
||||
{Id: commercial.OIDAllowSSO, Value: []byte("true")},
|
||||
{Id: commercial.OIDSupportLevel, Value: []byte("dedicated")},
|
||||
},
|
||||
}
|
||||
|
||||
leafDER, err := x509.CreateCertificate(rand.Reader, leafTmpl, rootCert, &leafKey.PublicKey, rootKey)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "create leaf cert: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})
|
||||
|
||||
if err := os.WriteFile(*outCert, leafPEM, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write cert: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Wrote license certificate to %s\n", *outCert)
|
||||
|
||||
if *outRootCA != "" {
|
||||
if err := os.WriteFile(*outRootCA, rootPEM, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write root CA: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Wrote root CA to %s\n", *outRootCA)
|
||||
}
|
||||
}
|
||||
262
commercial/commercial.go
Normal file
262
commercial/commercial.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package commercial
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const envLicenseCert = "YAO_LICENSE_CERT"
|
||||
|
||||
// Load discovers and verifies the commercial license certificate,
|
||||
// writing the result into the global License variable.
|
||||
// It never returns an error — failures degrade to community defaults.
|
||||
func Load(appRoot, product string) {
|
||||
License = DefaultLicense()
|
||||
License.Product = []string{product}
|
||||
|
||||
pemData, source := findCert(appRoot)
|
||||
if pemData == nil {
|
||||
log.Printf("[License] No license certificate found, running with community defaults")
|
||||
return
|
||||
}
|
||||
|
||||
info, err := verify(pemData, product)
|
||||
if err != nil {
|
||||
License.Source = source
|
||||
License.Error = err.Error()
|
||||
log.Printf("[License] %v — running with community defaults", err)
|
||||
return
|
||||
}
|
||||
|
||||
info.Source = source
|
||||
info.LoadedAt = time.Now().Unix()
|
||||
License = *info
|
||||
|
||||
if License.Valid {
|
||||
remaining := time.Until(time.Unix(License.NotAfter, 0))
|
||||
log.Printf("[License] Loaded: %s (%s) — valid until %s",
|
||||
License.LicenseeName, License.Edition,
|
||||
time.Unix(License.NotAfter, 0).UTC().Format("2006-01-02"))
|
||||
if remaining < 90*24*time.Hour {
|
||||
log.Printf("[License] WARNING: Certificate expires in %d days", int(remaining.Hours()/24))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findCert locates the license PEM data.
|
||||
// Search order:
|
||||
// 1. YAO_LICENSE_CERT env (PEM content or file path)
|
||||
// 2. <appRoot>/license.pem
|
||||
// 3. <appRoot>/certs/license.pem
|
||||
func findCert(appRoot string) (pemData []byte, source string) {
|
||||
if v := os.Getenv(envLicenseCert); v != "" {
|
||||
if strings.HasPrefix(v, "-----BEGIN") {
|
||||
return []byte(v), "env"
|
||||
}
|
||||
data, err := os.ReadFile(v)
|
||||
if err == nil {
|
||||
return data, "env"
|
||||
}
|
||||
log.Printf("[License] env %s points to unreadable file: %v", envLicenseCert, err)
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(appRoot, "license.pem"),
|
||||
filepath.Join(appRoot, "certs", "license.pem"),
|
||||
}
|
||||
for _, path := range candidates {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return data, "file"
|
||||
}
|
||||
}
|
||||
return nil, "none"
|
||||
}
|
||||
|
||||
// verify parses PEM data, validates the certificate chain against built-in
|
||||
// roots, checks revocation, time validity, product scope, and extracts
|
||||
// custom extension fields.
|
||||
func verify(pemData []byte, product string) (*LicenseInfo, error) {
|
||||
certs, err := ParsePEMChain(pemData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse PEM: %w", err)
|
||||
}
|
||||
if len(certs) == 0 {
|
||||
return nil, fmt.Errorf("no certificates found in PEM data")
|
||||
}
|
||||
|
||||
leaf := certs[0]
|
||||
|
||||
pool := RootPool()
|
||||
if pool == nil {
|
||||
return nil, fmt.Errorf("no root certificates available (development build)")
|
||||
}
|
||||
|
||||
// Verify the trust chain with a synthetic time within the leaf's validity
|
||||
// window. This lets us extract structured info from expired/future
|
||||
// certificates instead of returning an opaque x509 error.
|
||||
// We use NotAfter-1s (just before expiry) to maximize overlap with CA validity.
|
||||
opts := x509.VerifyOptions{
|
||||
Roots: pool,
|
||||
CurrentTime: leaf.NotAfter.Add(-time.Second),
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}
|
||||
|
||||
if len(certs) > 1 {
|
||||
intermediates := x509.NewCertPool()
|
||||
for _, c := range certs[1:] {
|
||||
intermediates.AddCert(c)
|
||||
}
|
||||
opts.Intermediates = intermediates
|
||||
}
|
||||
|
||||
if _, err := leaf.Verify(opts); err != nil {
|
||||
return nil, fmt.Errorf("certificate verification failed: %w", err)
|
||||
}
|
||||
|
||||
if IsRevoked(leaf.SerialNumber) {
|
||||
return nil, fmt.Errorf("certificate serial %s has been revoked", leaf.SerialNumber.Text(16))
|
||||
}
|
||||
|
||||
info := extractIdentity(leaf)
|
||||
parseExtensions(leaf, info)
|
||||
|
||||
now := time.Now()
|
||||
if now.Before(leaf.NotBefore) {
|
||||
info.Valid = false
|
||||
info.Error = fmt.Sprintf("certificate not yet valid (starts %s)",
|
||||
leaf.NotBefore.UTC().Format("2006-01-02"))
|
||||
return info, nil
|
||||
}
|
||||
if now.After(leaf.NotAfter) {
|
||||
info.Valid = false
|
||||
info.IsExpired = true
|
||||
info.Error = fmt.Sprintf("certificate expired on %s",
|
||||
leaf.NotAfter.UTC().Format("2006-01-02"))
|
||||
return info, nil
|
||||
}
|
||||
|
||||
if !info.HasProduct(product) {
|
||||
info.Valid = false
|
||||
info.Error = fmt.Sprintf("certificate not licensed for product %q (licensed: %v)",
|
||||
product, info.Product)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
info.Valid = true
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func extractIdentity(cert *x509.Certificate) *LicenseInfo {
|
||||
info := &LicenseInfo{
|
||||
LicenseeName: cert.Subject.CommonName,
|
||||
SerialNumber: cert.SerialNumber.Text(16),
|
||||
NotBefore: cert.NotBefore.Unix(),
|
||||
NotAfter: cert.NotAfter.Unix(),
|
||||
Issuer: cert.Issuer.CommonName,
|
||||
Edition: "community",
|
||||
Product: []string{},
|
||||
Permissions: Permissions{SupportLevel: "none"},
|
||||
}
|
||||
if len(cert.Subject.Organization) > 0 {
|
||||
info.LicenseeOrg = cert.Subject.Organization[0]
|
||||
}
|
||||
if len(cert.Subject.Country) > 0 {
|
||||
info.LicenseeCountry = cert.Subject.Country[0]
|
||||
}
|
||||
if len(cert.EmailAddresses) > 0 {
|
||||
info.LicenseeEmail = cert.EmailAddresses[0]
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func parseExtensions(cert *x509.Certificate, info *LicenseInfo) {
|
||||
for _, ext := range cert.Extensions {
|
||||
val := string(ext.Value)
|
||||
|
||||
switch {
|
||||
// Scope
|
||||
case ext.Id.Equal(OIDProduct):
|
||||
info.Product = splitCSV(val)
|
||||
case ext.Id.Equal(OIDEdition):
|
||||
info.Edition = val
|
||||
case ext.Id.Equal(OIDEnv):
|
||||
if val != "" {
|
||||
info.Env = splitCSV(val)
|
||||
}
|
||||
case ext.Id.Equal(OIDDomain):
|
||||
info.Domain = val
|
||||
case ext.Id.Equal(OIDAppID):
|
||||
info.AppID = val
|
||||
|
||||
// Quota
|
||||
case ext.Id.Equal(OIDMaxUsers):
|
||||
info.MaxUsers = atoi(val)
|
||||
case ext.Id.Equal(OIDMaxTaiNodes):
|
||||
info.MaxTaiNodes = atoi(val)
|
||||
case ext.Id.Equal(OIDMaxAgents):
|
||||
info.MaxAgents = atoi(val)
|
||||
case ext.Id.Equal(OIDMaxSandboxes):
|
||||
info.MaxSandboxes = atoi(val)
|
||||
case ext.Id.Equal(OIDMaxAPIRPM):
|
||||
info.MaxAPIRPM = atoi(val)
|
||||
case ext.Id.Equal(OIDMaxStorageGB):
|
||||
info.MaxStorageGB = atoi(val)
|
||||
|
||||
// Permissions
|
||||
case ext.Id.Equal(OIDAllowBrandingRemoval):
|
||||
info.Permissions.AllowBrandingRemoval = toBool(val)
|
||||
case ext.Id.Equal(OIDAllowWhiteLabel):
|
||||
info.Permissions.AllowWhiteLabel = toBool(val)
|
||||
case ext.Id.Equal(OIDAllowMultiTenant):
|
||||
info.Permissions.AllowMultiTenant = toBool(val)
|
||||
case ext.Id.Equal(OIDAllowCustomDomain):
|
||||
info.Permissions.AllowCustomDomain = toBool(val)
|
||||
case ext.Id.Equal(OIDAllowHostExec):
|
||||
info.Permissions.AllowHostExec = toBool(val)
|
||||
case ext.Id.Equal(OIDAllowSSO):
|
||||
info.Permissions.AllowSSO = toBool(val)
|
||||
case ext.Id.Equal(OIDSupportLevel):
|
||||
info.Permissions.SupportLevel = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MakeExtension creates a pkix.Extension for embedding in a certificate.
|
||||
func MakeExtension(oid asn1.ObjectIdentifier, value string) ExtensionValue {
|
||||
return ExtensionValue{OID: oid, Value: value}
|
||||
}
|
||||
|
||||
// ExtensionValue pairs an OID with its string value for certificate generation.
|
||||
type ExtensionValue struct {
|
||||
OID asn1.ObjectIdentifier
|
||||
Value string
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
var result []string
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||
return n
|
||||
}
|
||||
|
||||
func toBool(s string) bool {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
return s == "true" || s == "1" || s == "yes"
|
||||
}
|
||||
446
commercial/commercial_test.go
Normal file
446
commercial/commercial_test.go
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
package commercial
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// withTestRootPool temporarily replaces the root pool and revocation list
|
||||
// for testing, restoring originals on cleanup.
|
||||
func withTestRootPool(t *testing.T, ca *testCA, revoked []*big.Int) {
|
||||
t.Helper()
|
||||
|
||||
origPool := rootPool
|
||||
origOnce := rootPoolOnce
|
||||
origSerials := revokedSerials
|
||||
origRevokedOnce := revokedOnce
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.Cert)
|
||||
rootPool = pool
|
||||
rootPoolOnce = sync.Once{}
|
||||
rootPoolOnce.Do(func() {}) // mark as done so RootPool() returns our pool
|
||||
revokedSerials = revoked
|
||||
revokedOnce = sync.Once{}
|
||||
revokedOnce.Do(func() {}) // mark as done
|
||||
|
||||
t.Cleanup(func() {
|
||||
rootPool = origPool
|
||||
rootPoolOnce = origOnce
|
||||
revokedSerials = origSerials
|
||||
revokedOnce = origRevokedOnce
|
||||
})
|
||||
}
|
||||
|
||||
func TestNoCertificate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
License = DefaultLicense()
|
||||
Load(dir, "yao")
|
||||
|
||||
if License.Valid {
|
||||
t.Fatal("expected Valid=false with no certificate")
|
||||
}
|
||||
if License.Source != "none" {
|
||||
t.Fatalf("expected Source=none, got %s", License.Source)
|
||||
}
|
||||
if License.Edition != "community" {
|
||||
t.Fatalf("expected Edition=community, got %s", License.Edition)
|
||||
}
|
||||
if License.MaxUsers != 100 {
|
||||
t.Fatalf("expected MaxUsers=100, got %d", License.MaxUsers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidCertificate(t *testing.T) {
|
||||
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withTestRootPool(t, root, nil)
|
||||
|
||||
opts := defaultLicenseOpts()
|
||||
leaf, err := generateLicenseCert(root, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Load(dir, "yao")
|
||||
|
||||
if !License.Valid {
|
||||
t.Fatalf("expected Valid=true, got error: %s", License.Error)
|
||||
}
|
||||
if License.Source != "file" {
|
||||
t.Fatalf("expected Source=file, got %s", License.Source)
|
||||
}
|
||||
if License.Edition != "pro" {
|
||||
t.Fatalf("expected Edition=pro, got %s", License.Edition)
|
||||
}
|
||||
if License.LicenseeName != "Test Corp" {
|
||||
t.Fatalf("expected LicenseeName=Test Corp, got %s", License.LicenseeName)
|
||||
}
|
||||
if License.MaxUsers != 500 {
|
||||
t.Fatalf("expected MaxUsers=500, got %d", License.MaxUsers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredCertificate(t *testing.T) {
|
||||
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withTestRootPool(t, root, nil)
|
||||
|
||||
opts := defaultLicenseOpts()
|
||||
opts.NotBefore = time.Now().Add(-30 * time.Minute)
|
||||
opts.NotAfter = time.Now().Add(-1 * time.Minute)
|
||||
leaf, err := generateLicenseCert(root, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644)
|
||||
|
||||
Load(dir, "yao")
|
||||
|
||||
if License.Valid {
|
||||
t.Fatal("expected Valid=false for expired certificate")
|
||||
}
|
||||
if !License.IsExpired {
|
||||
t.Fatal("expected IsExpired=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotYetValidCertificate(t *testing.T) {
|
||||
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withTestRootPool(t, root, nil)
|
||||
|
||||
opts := defaultLicenseOpts()
|
||||
opts.NotBefore = time.Now().Add(24 * time.Hour)
|
||||
opts.NotAfter = time.Now().Add(365 * 24 * time.Hour)
|
||||
leaf, err := generateLicenseCert(root, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644)
|
||||
|
||||
Load(dir, "yao")
|
||||
|
||||
if License.Valid {
|
||||
t.Fatal("expected Valid=false for not-yet-valid certificate")
|
||||
}
|
||||
if License.IsExpired {
|
||||
t.Fatal("expected IsExpired=false for future certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedCertificate(t *testing.T) {
|
||||
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withTestRootPool(t, root, nil)
|
||||
|
||||
// Generate with a different root (not in our pool) to simulate tampering
|
||||
fakeRoot, err := generateRootCA("Fake Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
opts := defaultLicenseOpts()
|
||||
leaf, err := generateLicenseCert(fakeRoot, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644)
|
||||
|
||||
Load(dir, "yao")
|
||||
|
||||
if License.Valid {
|
||||
t.Fatal("expected Valid=false for tampered certificate")
|
||||
}
|
||||
if License.Error == "" {
|
||||
t.Fatal("expected Error to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongProduct(t *testing.T) {
|
||||
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withTestRootPool(t, root, nil)
|
||||
|
||||
opts := defaultLicenseOpts()
|
||||
// Only licensed for "tai", not "yao"
|
||||
for i, ext := range opts.Extensions {
|
||||
if ext.OID.Equal(OIDProduct) {
|
||||
opts.Extensions[i].Value = "tai"
|
||||
}
|
||||
}
|
||||
leaf, err := generateLicenseCert(root, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644)
|
||||
|
||||
Load(dir, "yao")
|
||||
|
||||
if License.Valid {
|
||||
t.Fatal("expected Valid=false for wrong product")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateChainWithIntermediate(t *testing.T) {
|
||||
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withTestRootPool(t, root, nil)
|
||||
|
||||
intermediate, err := generateIntermediateCA("Test Intermediate CA", 3*365*24*time.Hour, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
opts := defaultLicenseOpts()
|
||||
leaf, err := generateLicenseCert(intermediate, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// PEM chain: leaf + intermediate
|
||||
chainPEM := append(leaf.CertPEM, intermediate.CertPEM...)
|
||||
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "license.pem"), chainPEM, 0644)
|
||||
|
||||
Load(dir, "yao")
|
||||
|
||||
if !License.Valid {
|
||||
t.Fatalf("expected Valid=true with intermediate chain, got error: %s", License.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokedCertificate(t *testing.T) {
|
||||
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
opts := defaultLicenseOpts()
|
||||
opts.Serial = big.NewInt(99999)
|
||||
leaf, err := generateLicenseCert(root, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
withTestRootPool(t, root, []*big.Int{big.NewInt(99999)})
|
||||
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644)
|
||||
|
||||
Load(dir, "yao")
|
||||
|
||||
if License.Valid {
|
||||
t.Fatal("expected Valid=false for revoked certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvVarLoading(t *testing.T) {
|
||||
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withTestRootPool(t, root, nil)
|
||||
|
||||
opts := defaultLicenseOpts()
|
||||
leaf, err := generateLicenseCert(root, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write cert to a temp file and point env var to it
|
||||
certFile := filepath.Join(t.TempDir(), "test-license.pem")
|
||||
os.WriteFile(certFile, leaf.CertPEM, 0644)
|
||||
|
||||
t.Setenv(envLicenseCert, certFile)
|
||||
|
||||
Load(t.TempDir(), "yao")
|
||||
|
||||
if !License.Valid {
|
||||
t.Fatalf("expected Valid=true via env, got error: %s", License.Error)
|
||||
}
|
||||
if License.Source != "env" {
|
||||
t.Fatalf("expected Source=env, got %s", License.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllExtensions(t *testing.T) {
|
||||
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withTestRootPool(t, root, nil)
|
||||
|
||||
opts := defaultLicenseOpts()
|
||||
opts.Extensions = []ExtensionValue{
|
||||
{OID: OIDProduct, Value: "yao,tai"},
|
||||
{OID: OIDEdition, Value: "enterprise"},
|
||||
{OID: OIDEnv, Value: "production,staging"},
|
||||
{OID: OIDDomain, Value: "*.acme.com"},
|
||||
{OID: OIDAppID, Value: "acme-crm"},
|
||||
{OID: OIDMaxUsers, Value: "0"},
|
||||
{OID: OIDMaxTaiNodes, Value: "0"},
|
||||
{OID: OIDMaxAgents, Value: "0"},
|
||||
{OID: OIDMaxSandboxes, Value: "0"},
|
||||
{OID: OIDMaxAPIRPM, Value: "0"},
|
||||
{OID: OIDMaxStorageGB, Value: "0"},
|
||||
{OID: OIDAllowBrandingRemoval, Value: "true"},
|
||||
{OID: OIDAllowWhiteLabel, Value: "true"},
|
||||
{OID: OIDAllowMultiTenant, Value: "true"},
|
||||
{OID: OIDAllowCustomDomain, Value: "true"},
|
||||
{OID: OIDAllowHostExec, Value: "true"},
|
||||
{OID: OIDAllowSSO, Value: "true"},
|
||||
{OID: OIDSupportLevel, Value: "dedicated"},
|
||||
}
|
||||
|
||||
leaf, err := generateLicenseCert(root, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644)
|
||||
|
||||
Load(dir, "yao")
|
||||
|
||||
if !License.Valid {
|
||||
t.Fatalf("expected Valid=true, got error: %s", License.Error)
|
||||
}
|
||||
if License.Edition != "enterprise" {
|
||||
t.Fatalf("expected enterprise, got %s", License.Edition)
|
||||
}
|
||||
if !License.HasProduct("yao") || !License.HasProduct("tai") {
|
||||
t.Fatalf("expected product yao,tai, got %v", License.Product)
|
||||
}
|
||||
if len(License.Env) != 2 {
|
||||
t.Fatalf("expected 2 envs, got %v", License.Env)
|
||||
}
|
||||
if License.Domain != "*.acme.com" {
|
||||
t.Fatalf("expected domain *.acme.com, got %s", License.Domain)
|
||||
}
|
||||
if License.AppID != "acme-crm" {
|
||||
t.Fatalf("expected app_id acme-crm, got %s", License.AppID)
|
||||
}
|
||||
if License.MaxUsers != 0 {
|
||||
t.Fatalf("expected MaxUsers=0 (unlimited), got %d", License.MaxUsers)
|
||||
}
|
||||
if !License.Permissions.AllowBrandingRemoval {
|
||||
t.Fatal("expected AllowBrandingRemoval=true")
|
||||
}
|
||||
if !License.Permissions.AllowWhiteLabel {
|
||||
t.Fatal("expected AllowWhiteLabel=true")
|
||||
}
|
||||
if !License.Permissions.AllowMultiTenant {
|
||||
t.Fatal("expected AllowMultiTenant=true")
|
||||
}
|
||||
if !License.Permissions.AllowSSO {
|
||||
t.Fatal("expected AllowSSO=true")
|
||||
}
|
||||
if License.Permissions.SupportLevel != "dedicated" {
|
||||
t.Fatalf("expected SupportLevel=dedicated, got %s", License.Permissions.SupportLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultLicenseAndHelpers(t *testing.T) {
|
||||
def := DefaultLicense()
|
||||
|
||||
if def.Valid {
|
||||
t.Fatal("default should not be Valid")
|
||||
}
|
||||
if def.Edition != "community" {
|
||||
t.Fatalf("expected community, got %s", def.Edition)
|
||||
}
|
||||
if !def.IsLevel("community") {
|
||||
t.Fatal("community should satisfy IsLevel(community)")
|
||||
}
|
||||
if def.IsLevel("starter") {
|
||||
t.Fatal("community should not satisfy IsLevel(starter)")
|
||||
}
|
||||
if def.IsLevel("pro") {
|
||||
t.Fatal("community should not satisfy IsLevel(pro)")
|
||||
}
|
||||
|
||||
pro := LicenseInfo{Edition: "pro"}
|
||||
if !pro.IsLevel("community") {
|
||||
t.Fatal("pro should satisfy IsLevel(community)")
|
||||
}
|
||||
if !pro.IsLevel("starter") {
|
||||
t.Fatal("pro should satisfy IsLevel(starter)")
|
||||
}
|
||||
if !pro.IsLevel("pro") {
|
||||
t.Fatal("pro should satisfy IsLevel(pro)")
|
||||
}
|
||||
if pro.IsLevel("enterprise") {
|
||||
t.Fatal("pro should not satisfy IsLevel(enterprise)")
|
||||
}
|
||||
|
||||
multi := LicenseInfo{Product: []string{"yao", "tai"}}
|
||||
if !multi.HasProduct("yao") {
|
||||
t.Fatal("expected HasProduct(yao)=true")
|
||||
}
|
||||
if !multi.HasProduct("tai") {
|
||||
t.Fatal("expected HasProduct(tai)=true")
|
||||
}
|
||||
if multi.HasProduct("other") {
|
||||
t.Fatal("expected HasProduct(other)=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertsSubdirectoryFallback(t *testing.T) {
|
||||
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withTestRootPool(t, root, nil)
|
||||
|
||||
opts := defaultLicenseOpts()
|
||||
leaf, err := generateLicenseCert(root, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
certsDir := filepath.Join(dir, "certs")
|
||||
os.MkdirAll(certsDir, 0755)
|
||||
os.WriteFile(filepath.Join(certsDir, "license.pem"), leaf.CertPEM, 0644)
|
||||
|
||||
Load(dir, "yao")
|
||||
|
||||
if !License.Valid {
|
||||
t.Fatalf("expected Valid=true from certs/ fallback, got error: %s", License.Error)
|
||||
}
|
||||
if License.Source != "file" {
|
||||
t.Fatalf("expected Source=file, got %s", License.Source)
|
||||
}
|
||||
}
|
||||
38
commercial/oid.go
Normal file
38
commercial/oid.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package commercial
|
||||
|
||||
import "encoding/asn1"
|
||||
|
||||
// OID prefix: 1.3.6.1.4.1.15099.1
|
||||
// 15099 is Yao's internal port number, used as a recognizable enterprise number
|
||||
// for this closed-loop certificate system. Not registered with IANA.
|
||||
|
||||
var (
|
||||
// Scope
|
||||
OIDProduct = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 1}
|
||||
OIDEdition = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 2}
|
||||
OIDEnv = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 3}
|
||||
OIDDomain = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 4}
|
||||
OIDAppID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 5}
|
||||
|
||||
// Quota
|
||||
OIDMaxUsers = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 1}
|
||||
OIDMaxTaiNodes = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 2}
|
||||
OIDMaxAgents = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 3}
|
||||
OIDMaxSandboxes = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 4}
|
||||
OIDMaxAPIRPM = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 5}
|
||||
OIDMaxStorageGB = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 6}
|
||||
|
||||
// Permissions
|
||||
OIDAllowBrandingRemoval = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 1}
|
||||
OIDAllowWhiteLabel = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 2}
|
||||
OIDAllowMultiTenant = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 3}
|
||||
OIDAllowCustomDomain = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 4}
|
||||
OIDAllowHostExec = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 5}
|
||||
OIDAllowSSO = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 6}
|
||||
OIDSupportLevel = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 7}
|
||||
|
||||
// Issuance (internal tracking)
|
||||
OIDIssuerID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 1}
|
||||
OIDOrderID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 2}
|
||||
OIDNote = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 3}
|
||||
)
|
||||
98
commercial/roots.go
Normal file
98
commercial/roots.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package commercial
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed roots/root-ca-1.pem
|
||||
var rootCA1PEM []byte
|
||||
|
||||
//go:embed roots/root-ca-2.pem
|
||||
var rootCA2PEM []byte
|
||||
|
||||
//go:embed roots/revoked.json
|
||||
var revokedJSON []byte
|
||||
|
||||
var (
|
||||
rootPoolOnce sync.Once
|
||||
rootPool *x509.CertPool
|
||||
|
||||
revokedOnce sync.Once
|
||||
revokedSerials []*big.Int
|
||||
)
|
||||
|
||||
// RootPool returns the built-in root certificate pool (primary + backup).
|
||||
// Returns nil if neither root certificate could be parsed (e.g. dev placeholder).
|
||||
func RootPool() *x509.CertPool {
|
||||
rootPoolOnce.Do(func() {
|
||||
pool := x509.NewCertPool()
|
||||
added := false
|
||||
for _, pemData := range [][]byte{rootCA1PEM, rootCA2PEM} {
|
||||
if pool.AppendCertsFromPEM(pemData) {
|
||||
added = true
|
||||
}
|
||||
}
|
||||
if added {
|
||||
rootPool = pool
|
||||
}
|
||||
})
|
||||
return rootPool
|
||||
}
|
||||
|
||||
// RevokedSerials returns the list of revoked certificate serial numbers
|
||||
// embedded in the binary.
|
||||
func RevokedSerials() []*big.Int {
|
||||
revokedOnce.Do(func() {
|
||||
var data struct {
|
||||
Serials []string `json:"serials"`
|
||||
}
|
||||
if err := json.Unmarshal(revokedJSON, &data); err != nil {
|
||||
return
|
||||
}
|
||||
for _, s := range data.Serials {
|
||||
n := new(big.Int)
|
||||
if _, ok := n.SetString(s, 0); ok {
|
||||
revokedSerials = append(revokedSerials, n)
|
||||
}
|
||||
}
|
||||
})
|
||||
return revokedSerials
|
||||
}
|
||||
|
||||
// IsRevoked checks whether the given serial number is in the revocation list.
|
||||
func IsRevoked(serial *big.Int) bool {
|
||||
for _, s := range RevokedSerials() {
|
||||
if s.Cmp(serial) == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParsePEMChain parses a PEM bundle into a list of x509 certificates.
|
||||
// The first certificate is treated as the leaf; remaining are intermediates.
|
||||
func ParsePEMChain(pemData []byte) ([]*x509.Certificate, error) {
|
||||
var certs []*x509.Certificate
|
||||
rest := pemData
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
1
commercial/roots/revoked.json
Normal file
1
commercial/roots/revoked.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"serials": []}
|
||||
14
commercial/roots/root-ca-1.pem
Normal file
14
commercial/roots/root-ca-1.pem
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIICFzCCAZygAwIBAgIQYJYI6zEyHfyDlfFW2p4NHDAKBggqhkjOPQQDAzBLMQsw
|
||||
CQYDVQQGEwJDTjEhMB8GA1UEChMYSW5maW5pdGUgV2lzZG9tIFNvZnR3YXJlMRkw
|
||||
FwYDVQQDExBZYW8gVGVzdCBSb290IENBMB4XDTI2MDMyNzAzMzYxMVoXDTM2MDMy
|
||||
NDA0MzYxMVowSzELMAkGA1UEBhMCQ04xITAfBgNVBAoTGEluZmluaXRlIFdpc2Rv
|
||||
bSBTb2Z0d2FyZTEZMBcGA1UEAxMQWWFvIFRlc3QgUm9vdCBDQTB2MBAGByqGSM49
|
||||
AgEGBSuBBAAiA2IABNe4Y3nl17lCgK3dZaGFYPdeutdm/hdprjwynJPfGBnw84oE
|
||||
UBIgm7Nd5hpM57xZak9oH4ol58sOTxjWL492JWX+jU9pRUYn042HA4imSmmrxzP3
|
||||
83m8Kn9VcYSEKPJzmKNFMEMwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYB
|
||||
Af8CAQEwHQYDVR0OBBYEFGh4E/o55y4xR3qa7ZUSIVoxpHdlMAoGCCqGSM49BAMD
|
||||
A2kAMGYCMQDLhwvg8SDmMiwHCejwtLBozFLcdkys/MoFVnueUE1RAQo1G0CitB/y
|
||||
UJaYwCnV3j0CMQDe09TLfSA/69XDsv4ueNq9UtY9+ysEzkD2zxupOL1MYSPGNN0a
|
||||
/ct7CLnLDOnRsN8=
|
||||
-----END CERTIFICATE-----
|
||||
4
commercial/roots/root-ca-2.pem
Normal file
4
commercial/roots/root-ca-2.pem
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-----BEGIN PLACEHOLDER-----
|
||||
This is a development placeholder. The real backup root certificate will be
|
||||
injected by the release workflow via GitHub Secrets before compilation.
|
||||
-----END PLACEHOLDER-----
|
||||
177
commercial/testhelper_test.go
Normal file
177
commercial/testhelper_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package commercial
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testCA holds a generated CA certificate and its private key.
|
||||
type testCA struct {
|
||||
Cert *x509.Certificate
|
||||
Key *ecdsa.PrivateKey
|
||||
CertPEM []byte
|
||||
}
|
||||
|
||||
// testLicenseCert holds a generated leaf (license) certificate.
|
||||
type testLicenseCert struct {
|
||||
Cert *x509.Certificate
|
||||
CertPEM []byte
|
||||
}
|
||||
|
||||
// generateRootCA creates a self-signed ECDSA P-384 root CA for testing.
|
||||
func generateRootCA(cn string, validity time.Duration) (*testCA, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: cn,
|
||||
Organization: []string{"Infinite Wisdom Software"},
|
||||
Country: []string{"CN"},
|
||||
},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(validity),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 1,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
return &testCA{Cert: cert, Key: key, CertPEM: certPEM}, nil
|
||||
}
|
||||
|
||||
// generateIntermediateCA creates an intermediate CA signed by the given parent.
|
||||
func generateIntermediateCA(cn string, validity time.Duration, parent *testCA) (*testCA, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: cn,
|
||||
Organization: []string{"Test Partner Inc."},
|
||||
Country: []string{"US"},
|
||||
},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(validity),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 0,
|
||||
MaxPathLenZero: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, parent.Cert, &key.PublicKey, parent.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
return &testCA{Cert: cert, Key: key, CertPEM: certPEM}, nil
|
||||
}
|
||||
|
||||
// licenseOpts configures a test license certificate.
|
||||
type licenseOpts struct {
|
||||
CN string
|
||||
Org string
|
||||
Country string
|
||||
Email string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
Serial *big.Int
|
||||
Extensions []ExtensionValue
|
||||
}
|
||||
|
||||
func defaultLicenseOpts() licenseOpts {
|
||||
return licenseOpts{
|
||||
CN: "Test Corp",
|
||||
Org: "Test Inc.",
|
||||
Country: "CN",
|
||||
Email: "test@example.com",
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
Serial: big.NewInt(10042),
|
||||
Extensions: []ExtensionValue{
|
||||
{OID: OIDProduct, Value: "yao"},
|
||||
{OID: OIDEdition, Value: "pro"},
|
||||
{OID: OIDMaxUsers, Value: "500"},
|
||||
{OID: OIDMaxTaiNodes, Value: "10"},
|
||||
{OID: OIDMaxAgents, Value: "50"},
|
||||
{OID: OIDMaxSandboxes, Value: "20"},
|
||||
{OID: OIDMaxAPIRPM, Value: "10000"},
|
||||
{OID: OIDMaxStorageGB, Value: "100"},
|
||||
{OID: OIDSupportLevel, Value: "priority"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// generateLicenseCert creates a leaf license certificate signed by the given CA.
|
||||
func generateLicenseCert(signer *testCA, opts licenseOpts) (*testLicenseCert, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: opts.Serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: opts.CN,
|
||||
Organization: []string{opts.Org},
|
||||
Country: []string{opts.Country},
|
||||
},
|
||||
EmailAddresses: []string{opts.Email},
|
||||
NotBefore: opts.NotBefore,
|
||||
NotAfter: opts.NotAfter,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}
|
||||
|
||||
for _, ext := range opts.Extensions {
|
||||
tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, pkix.Extension{
|
||||
Id: ext.OID,
|
||||
Value: []byte(ext.Value),
|
||||
})
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, signer.Cert, &key.PublicKey, signer.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
return &testLicenseCert{Cert: cert, CertPEM: certPEM}, nil
|
||||
}
|
||||
114
commercial/types.go
Normal file
114
commercial/types.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package commercial
|
||||
|
||||
import "time"
|
||||
|
||||
// License is the global commercial license state, populated by Load().
|
||||
// Read-only after initialization; all modules may read without synchronization.
|
||||
var License LicenseInfo
|
||||
|
||||
// LicenseInfo holds the parsed result of a commercial license certificate.
|
||||
type LicenseInfo struct {
|
||||
Valid bool `json:"valid"`
|
||||
Source string `json:"source"` // "none" | "file" | "env"
|
||||
LoadedAt int64 `json:"loaded_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// Identity (from X.509 Subject)
|
||||
LicenseeName string `json:"licensee_name"`
|
||||
LicenseeOrg string `json:"licensee_org"`
|
||||
LicenseeCountry string `json:"licensee_country,omitempty"`
|
||||
LicenseeEmail string `json:"licensee_email,omitempty"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
NotBefore int64 `json:"not_before"`
|
||||
NotAfter int64 `json:"not_after"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
Issuer string `json:"issuer"`
|
||||
|
||||
// Scope
|
||||
Product []string `json:"product"`
|
||||
Edition string `json:"edition"` // "community" | "starter" | "pro" | "enterprise"
|
||||
Env []string `json:"env,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
|
||||
// Quota (0 = unlimited)
|
||||
MaxUsers int `json:"max_users"`
|
||||
MaxTaiNodes int `json:"max_tai_nodes"`
|
||||
MaxAgents int `json:"max_agents"`
|
||||
MaxSandboxes int `json:"max_sandboxes"`
|
||||
MaxAPIRPM int `json:"max_api_rpm"`
|
||||
MaxStorageGB int `json:"max_storage_gb"`
|
||||
|
||||
// Permissions
|
||||
Permissions Permissions `json:"permissions"`
|
||||
}
|
||||
|
||||
// Permissions controls feature switches.
|
||||
type Permissions struct {
|
||||
AllowBrandingRemoval bool `json:"allow_branding_removal"`
|
||||
AllowWhiteLabel bool `json:"allow_white_label"`
|
||||
AllowMultiTenant bool `json:"allow_multi_tenant"`
|
||||
AllowCustomDomain bool `json:"allow_custom_domain"`
|
||||
AllowHostExec bool `json:"allow_host_exec"`
|
||||
AllowSSO bool `json:"allow_sso"`
|
||||
SupportLevel string `json:"support_level"` // "none" | "email" | "priority" | "dedicated"
|
||||
}
|
||||
|
||||
// PublicInfo is the subset safe for well-known / public API exposure.
|
||||
type PublicInfo struct {
|
||||
Valid bool `json:"valid"`
|
||||
Edition string `json:"edition"`
|
||||
NotAfter int64 `json:"not_after,omitempty"`
|
||||
Product []string `json:"product"`
|
||||
}
|
||||
|
||||
// DefaultLicense returns community-level defaults when no certificate is present.
|
||||
func DefaultLicense() LicenseInfo {
|
||||
return LicenseInfo{
|
||||
Source: "none",
|
||||
LoadedAt: time.Now().Unix(),
|
||||
Edition: "community",
|
||||
Product: []string{"yao"},
|
||||
MaxUsers: 100,
|
||||
MaxTaiNodes: 1,
|
||||
MaxAgents: 3,
|
||||
MaxSandboxes: 1,
|
||||
MaxAPIRPM: 1000,
|
||||
MaxStorageGB: 10,
|
||||
Permissions: Permissions{
|
||||
SupportLevel: "none",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetPublicInfo returns the public-safe subset of the current license state.
|
||||
func GetPublicInfo() *PublicInfo {
|
||||
return &PublicInfo{
|
||||
Valid: License.Valid,
|
||||
Edition: License.Edition,
|
||||
NotAfter: License.NotAfter,
|
||||
Product: License.Product,
|
||||
}
|
||||
}
|
||||
|
||||
var editionRank = map[string]int{
|
||||
"community": 0,
|
||||
"starter": 1,
|
||||
"pro": 2,
|
||||
"enterprise": 3,
|
||||
}
|
||||
|
||||
// IsLevel reports whether the license meets or exceeds the given minimum edition.
|
||||
func (l LicenseInfo) IsLevel(minEdition string) bool {
|
||||
return editionRank[l.Edition] >= editionRank[minEdition]
|
||||
}
|
||||
|
||||
// HasProduct reports whether the license covers the given product name.
|
||||
func (l LicenseInfo) HasProduct(product string) bool {
|
||||
for _, p := range l.Product {
|
||||
if p == product {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/yaoapp/yao/api"
|
||||
"github.com/yaoapp/yao/attachment"
|
||||
"github.com/yaoapp/yao/cert"
|
||||
"github.com/yaoapp/yao/commercial"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/connector"
|
||||
"github.com/yaoapp/yao/data"
|
||||
|
|
@ -149,6 +150,12 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
|
|||
warnings = append(warnings, Warning{Widget: "Registry", Error: err})
|
||||
}
|
||||
|
||||
// Load Commercial License
|
||||
loadStep("License", func() error {
|
||||
commercial.Load(cfg.Root, "yao")
|
||||
return nil
|
||||
}, callback)
|
||||
|
||||
// Load Certs
|
||||
err = loadStep("Cert", func() error {
|
||||
return cert.Load(cfg)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/commercial"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/share"
|
||||
)
|
||||
|
|
@ -52,6 +53,9 @@ type YaoMetadata struct {
|
|||
GRPC string `json:"grpc,omitempty"` // gRPC server address (e.g., "127.0.0.1:9099")
|
||||
Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings
|
||||
|
||||
// Commercial license
|
||||
License *commercial.PublicInfo `json:"license,omitempty"`
|
||||
|
||||
// Developer information
|
||||
Developer *share.Developer `json:"developer,omitempty"`
|
||||
}
|
||||
|
|
@ -76,6 +80,9 @@ func (openapi *OpenAPI) yaoMetadata(c *gin.Context) {
|
|||
Optional: share.App.Optional,
|
||||
}
|
||||
|
||||
// Include license info
|
||||
metadata.License = commercial.GetPublicInfo()
|
||||
|
||||
// Include developer info if available
|
||||
if share.App.Developer.ID != "" || share.App.Developer.Name != "" {
|
||||
metadata.Developer = &share.App.Developer
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue