fix: harden license lifecycle (#547)

This commit is contained in:
sagit
2026-08-13 09:25:05 +08:00
committed by GitHub
parent c56798e991
commit 5041c722c9
17 changed files with 734 additions and 85 deletions
+86 -8
View File
@@ -6,10 +6,13 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
var AccountID string
type KeygenClient struct {
AccountID string
Token string
@@ -17,6 +20,20 @@ type KeygenClient struct {
HTTPClient *http.Client
}
type APIError struct {
Operation string
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("keygen %s failed: status %d, response: %s", e.Operation, e.StatusCode, e.Body)
}
func (e *APIError) HasCode(code string) bool {
return e != nil && hasKeygenErrorCode([]byte(e.Body), code)
}
const defaultAPIBaseURL = "https://api.keygen.sh/v1"
func NewKeygenClient(accountID, token string) *KeygenClient {
@@ -47,6 +64,7 @@ type ValidateResponse struct {
Expiry string `json:"expiry"`
} `json:"attributes"`
} `json:"data"`
MachineID string `json:"-"`
}
type ActivateMachineRequest struct {
@@ -72,6 +90,12 @@ type keygenErrorResponse struct {
} `json:"errors"`
}
type MachineResponse struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
func hasKeygenErrorCode(body []byte, code string) bool {
var resp keygenErrorResponse
if err := json.Unmarshal(body, &resp); err != nil {
@@ -86,6 +110,10 @@ func hasKeygenErrorCode(body []byte, code string) bool {
}
func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) {
return c.ValidateKeyWithMachine(key, fingerprint, "")
}
func (c *KeygenClient) ValidateKeyWithMachine(key, fingerprint, machineID string) (*ValidateResponse, error) {
url := c.apiURL("licenses/actions/validate-key")
meta := map[string]interface{}{
@@ -97,6 +125,14 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
"fingerprint": fingerprint,
}
}
if machineID != "" {
scope, _ := meta["scope"].(map[string]interface{})
if scope == nil {
scope = make(map[string]interface{})
meta["scope"] = scope
}
scope["machine"] = machineID
}
reqBody := map[string]interface{}{
"meta": meta,
@@ -122,7 +158,8 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
}
var valResp ValidateResponse
@@ -133,6 +170,42 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
return &valResp, nil
}
func (c *KeygenClient) GetMachineID(fingerprint string) (string, error) {
machineURL := c.apiURL("machines/" + url.PathEscape(fingerprint))
req, err := http.NewRequest(http.MethodGet, machineURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.api+json")
if c.Token != "" {
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
req.Header.Set("Authorization", "License "+c.Token)
} else {
req.Header.Set("Authorization", c.Token)
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", &APIError{Operation: "retrieve machine", StatusCode: resp.StatusCode, Body: string(body)}
}
var machineResp MachineResponse
if err := json.NewDecoder(resp.Body).Decode(&machineResp); err != nil {
return "", err
}
machineID := strings.TrimSpace(machineResp.Data.ID)
if machineID == "" {
return "", fmt.Errorf("failed to retrieve machine: empty machine id")
}
return machineID, nil
}
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
url := c.apiURL("licenses/actions/validate-key")
@@ -161,7 +234,8 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
}
var valResp ValidateResponse
@@ -172,7 +246,7 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
return &valResp, nil
}
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) (string, error) {
url := c.apiURL("machines")
var reqBody ActivateMachineRequest
@@ -196,20 +270,24 @@ func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
return nil
var machineResp MachineResponse
if json.Unmarshal(body, &machineResp) == nil {
return strings.TrimSpace(machineResp.Data.ID), nil
}
return "", nil
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnprocessableEntity && hasKeygenErrorCode(body, "FINGERPRINT_TAKEN") {
// Machine activation is idempotent. Keygen scopes fingerprint uniqueness
// to the target license, so this means the same machine is already bound.
return nil
return "", nil
}
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
return "", &APIError{Operation: "activate machine", StatusCode: resp.StatusCode, Body: string(body)}
}
+66 -2
View File
@@ -1,6 +1,7 @@
package license
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -8,6 +9,54 @@ import (
"testing"
)
func TestValidateKeyWithMachineSendsFingerprintAndMachineScopes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Meta struct {
Scope map[string]string `json:"scope"`
} `json:"meta"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body.Meta.Scope["fingerprint"] != "fingerprint" {
t.Fatalf("fingerprint scope = %q", body.Meta.Scope["fingerprint"])
}
if body.Meta.Scope["machine"] != "machine-id" {
t.Fatalf("machine scope = %q", body.Meta.Scope["machine"])
}
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{}}}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "")
client.BaseURL = server.URL
validation, err := client.ValidateKeyWithMachine("license-key", "fingerprint", "machine-id")
if err != nil || !validation.Meta.Valid {
t.Fatalf("ValidateKeyWithMachine() validation=%+v err=%v", validation, err)
}
}
func TestGetMachineIDRetrievesMachineByFingerprint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/machines/fingerprint") {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("Authorization") != "License license-key" {
t.Fatalf("authorization = %q", r.Header.Get("Authorization"))
}
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
machineID, err := client.GetMachineID("fingerprint")
if err != nil || machineID != "machine-id" {
t.Fatalf("GetMachineID() = %q, %v", machineID, err)
}
}
func TestActivateMachineTreatsFingerprintTakenAsIdempotent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
@@ -18,11 +67,26 @@ func TestActivateMachineTreatsFingerprintTakenAsIdempotent(t *testing.T) {
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
if err := client.ActivateMachine("license-id", "fingerprint"); err != nil {
if machineID, err := client.ActivateMachine("license-id", "fingerprint"); err != nil || machineID != "" {
t.Fatalf("ActivateMachine() error = %v, want idempotent success", err)
}
}
func TestActivateMachineReturnsCreatedMachineID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
machineID, err := client.ActivateMachine("license-id", "fingerprint")
if err != nil || machineID != "machine-id" {
t.Fatalf("ActivateMachine() = %q, %v", machineID, err)
}
}
func TestActivateMachineRejectsMachineLimitWithoutFingerprintTaken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
@@ -33,7 +97,7 @@ func TestActivateMachineRejectsMachineLimitWithoutFingerprintTaken(t *testing.T)
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
err := client.ActivateMachine("license-id", "fingerprint")
_, err := client.ActivateMachine("license-id", "fingerprint")
if err == nil || !strings.Contains(err.Error(), "MACHINE_LIMIT_EXCEEDED") {
t.Fatalf("ActivateMachine() error = %v, want machine limit failure", err)
}