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
|
aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
env:
|
||||||
|
ROOT_CA_1: ${{ secrets.YAO_ROOT_CA_1_PEM }}
|
||||||
|
ROOT_CA_2: ${{ secrets.YAO_ROOT_CA_2_PEM }}
|
||||||
run: |
|
run: |
|
||||||
export PATH=$PATH:/github/home/go/bin
|
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-init.git /app/yao-init
|
||||||
git clone https://github.com/yaoapp/yao.git /app/yao
|
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
|
# Extract libv8
|
||||||
files=$(find /app/v8go -name "libv8*.zip")
|
files=$(find /app/v8go -name "libv8*.zip")
|
||||||
for file in $files; do
|
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
|
rm -f share/const.go.bak
|
||||||
grep 'const VERSION' share/const.go
|
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
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yaoapp/kun/maps"
|
"github.com/yaoapp/kun/maps"
|
||||||
"github.com/yaoapp/kun/utils"
|
"github.com/yaoapp/kun/utils"
|
||||||
|
"github.com/yaoapp/yao/commercial"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/engine"
|
"github.com/yaoapp/yao/engine"
|
||||||
"github.com/yaoapp/yao/share"
|
"github.com/yaoapp/yao/share"
|
||||||
|
|
@ -20,6 +21,7 @@ var inspectCmd = &cobra.Command{
|
||||||
"version": share.VERSION,
|
"version": share.VERSION,
|
||||||
"config": config.Conf,
|
"config": config.Conf,
|
||||||
}
|
}
|
||||||
|
res["license"] = commercial.License
|
||||||
if share.Tools != nil {
|
if share.Tools != nil {
|
||||||
res["tools"] = share.Tools
|
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/api"
|
||||||
"github.com/yaoapp/yao/attachment"
|
"github.com/yaoapp/yao/attachment"
|
||||||
"github.com/yaoapp/yao/cert"
|
"github.com/yaoapp/yao/cert"
|
||||||
|
"github.com/yaoapp/yao/commercial"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/connector"
|
"github.com/yaoapp/yao/connector"
|
||||||
"github.com/yaoapp/yao/data"
|
"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})
|
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
|
// Load Certs
|
||||||
err = loadStep("Cert", func() error {
|
err = loadStep("Cert", func() error {
|
||||||
return cert.Load(cfg)
|
return cert.Load(cfg)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/yaoapp/yao/commercial"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/share"
|
"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")
|
GRPC string `json:"grpc,omitempty"` // gRPC server address (e.g., "127.0.0.1:9099")
|
||||||
Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings
|
Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings
|
||||||
|
|
||||||
|
// Commercial license
|
||||||
|
License *commercial.PublicInfo `json:"license,omitempty"`
|
||||||
|
|
||||||
// Developer information
|
// Developer information
|
||||||
Developer *share.Developer `json:"developer,omitempty"`
|
Developer *share.Developer `json:"developer,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +80,9 @@ func (openapi *OpenAPI) yaoMetadata(c *gin.Context) {
|
||||||
Optional: share.App.Optional,
|
Optional: share.App.Optional,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include license info
|
||||||
|
metadata.License = commercial.GetPublicInfo()
|
||||||
|
|
||||||
// Include developer info if available
|
// Include developer info if available
|
||||||
if share.App.Developer.ID != "" || share.App.Developer.Name != "" {
|
if share.App.Developer.ID != "" || share.App.Developer.Name != "" {
|
||||||
metadata.Developer = &share.App.Developer
|
metadata.Developer = &share.App.Developer
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue