From 4f1fce32c195dc38f1222796d3f46eec3d7e9f92 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 24 Jul 2026 14:59:13 +0100 Subject: [PATCH] feat(license): add lkctl for issuing licences by hand --- shared/cmd/lkctl/main.go | 176 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 shared/cmd/lkctl/main.go diff --git a/shared/cmd/lkctl/main.go b/shared/cmd/lkctl/main.go new file mode 100644 index 0000000..0bb29e2 --- /dev/null +++ b/shared/cmd/lkctl/main.go @@ -0,0 +1,176 @@ +// Command lkctl issues and inspects Vantage licences by hand. +// +// lkctl keypair +// lkctl issue --instance-id= --instance-name="Acme" --tier=professional --term=1y +// lkctl inspect +// +// issue reads the signing key from LICENSE_SIGNING_KEY. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/google/uuid" + "github.com/hyperboloide/lk" + "github.com/mrhid6/vantage/shared/license" +) + +func main() { + if len(os.Args) < 2 { + usage() + } + switch os.Args[1] { + case "keypair": + keypair() + case "issue": + issue(os.Args[2:]) + case "inspect": + inspect(os.Args[2:]) + default: + usage() + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: lkctl keypair | issue | inspect") + os.Exit(2) +} + +func keypair() { + priv, err := lk.NewPrivateKey() + if err != nil { + fatal("generate key: %v", err) + } + privStr, err := priv.ToB32String() + if err != nil { + fatal("encode private key: %v", err) + } + // PublicKey.ToB32String returns one value, unlike its private counterpart. + pubStr := priv.GetPublicKey().ToB32String() + + fmt.Println("PRIVATE KEY (store in a password manager and in the admin service's") + fmt.Println("LICENSE_SIGNING_KEY; back it up in two places, it cannot be recovered):") + fmt.Println() + fmt.Println(privStr) + fmt.Println() + fmt.Println("PUBLIC KEY (paste into trustedPublicKeys in shared/license/keys.go):") + fmt.Println() + fmt.Println(pubStr) +} + +func issue(args []string) { + fs := flag.NewFlagSet("issue", flag.ExitOnError) + instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)") + instanceName := fs.String("instance-name", "", "display name") + accountID := fs.String("account-id", "", "admin-side account id, optional") + tier := fs.String("tier", "", "free | professional | self_hosted (required)") + term := fs.String("term", "1y", "1m or 1y") + expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term") + out := fs.String("out", "", "write the blob to this file instead of stdout") + fs.Parse(args) + + if *instanceID == "" || *tier == "" { + fatal("--instance-id and --tier are required") + } + + plan, ok := license.PlanFor(*tier) + if !ok { + fatal("unknown tier %q", *tier) + } + + key := os.Getenv("LICENSE_SIGNING_KEY") + if key == "" { + fatal("LICENSE_SIGNING_KEY is not set") + } + + now := time.Now().UTC() + var exp time.Time + switch { + case *expires != "": + t, err := time.Parse(time.RFC3339, *expires) + if err != nil { + fatal("parse --expires: %v", err) + } + exp = t.UTC() + case *term == "1m": + exp = now.AddDate(0, 1, 0) + case *term == "1y": + exp = now.AddDate(1, 0, 0) + default: + fatal("--term must be 1m or 1y") + } + + // Self Hosted is sold annually only, so the window in which a cancelled + // licence keeps working is bounded at a year. + if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" { + fatal("self_hosted is annual only; use --term=1y or an explicit --expires") + } + + name := *instanceName + if name == "" { + name = *instanceID + } + + l := license.License{ + ID: uuid.NewString(), + InstanceID: *instanceID, + AccountID: *accountID, + InstanceName: name, + Tier: plan.Tier, + Deployment: plan.Deployment, + IssuedAt: now, + ExpiresAt: exp, + Limits: plan.Limits, + Features: plan.Features, + } + + blob, err := license.Sign(l, key) + if err != nil { + fatal("%v", err) + } + + if *out != "" { + if err := os.WriteFile(*out, []byte(blob+"\n"), 0o600); err != nil { + fatal("write %s: %v", *out, err) + } + fmt.Fprintf(os.Stderr, "wrote %s (tier=%s deployment=%s expires=%s)\n", + *out, l.Tier, l.Deployment, l.ExpiresAt.Format(time.RFC3339)) + return + } + fmt.Println(blob) +} + +func inspect(args []string) { + if len(args) < 1 { + fatal("usage: lkctl inspect ") + } + blob := args[0] + if b, err := os.ReadFile(blob); err == nil { + blob = strings.TrimSpace(string(b)) + } + + l, err := license.Parse(blob) + if err != nil { + fatal("%v", err) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(l); err != nil { + fatal("%v", err) + } + + if time.Now().After(l.ExpiresAt) { + fmt.Fprintf(os.Stderr, "\nNOTE: expired %s\n", l.ExpiresAt.Format(time.RFC3339)) + } +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +}