Files
DDI/server/cmd/ddi-server/license.go
T
sir_melonchenandClaude Opus 5.5 df25705b6e feat(server): add license set-key command reading the key from stdin
Validates and activates the key like PUT /api/v1/license (same service
call, audited as the system actor) and prints the license state without
the key. Exit codes: 2 bad input, 3 key rejected, 4 license server
unreachable or untrusted (nothing stored).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:28:33 +02:00

172 lines
5.2 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/internal/brand"
"git.beugsiud.de/beug.k/DDI/server/internal/app"
"git.beugsiud.de/beug.k/DDI/server/internal/config"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
// Exit codes of `license set-key` besides 0 (stored) and 1 (other errors,
// e.g. configuration or database). The installer uses them to decide
// whether to warn or to fail.
const (
exitUsage = 2 // bad arguments, empty or oversized input
exitKeyRejected = 3 // the license service rejected the key
exitLicenseSvc = 4 // the license service is unreachable or untrusted
)
// maxKeyInput bounds what is read from stdin; maxKeyLength matches the
// limit of the license manager.
const (
maxKeyInput = 4096
maxKeyLength = 512
)
// exitError carries a specific exit code.
type exitError struct {
code int
err error
}
func (e *exitError) Error() string { return e.err.Error() }
func (e *exitError) Unwrap() error { return e.err }
func licenseUsage(w io.Writer) {
_, _ = fmt.Fprintf(w, `Usage: %[1]s license set-key < keyfile
set-key read a license key from stdin, validate and activate it online
(like PUT /api/v1/license) and print the resulting license state
Exit codes: 0 activated, 1 other error, 2 empty or oversized input,
3 key rejected, 4 license server unreachable (nothing was changed).
`, brand.ServerBinary)
}
func cmdLicense(ctx context.Context, args []string, lookup config.LookupFunc, stdin io.Reader, stdout, logOut io.Writer) error {
if len(args) == 0 || args[0] == "help" || args[0] == "-h" || args[0] == "--help" {
if len(args) == 0 {
licenseUsage(logOut)
return &exitError{code: exitUsage, err: errors.New("license: missing subcommand")}
}
licenseUsage(stdout)
return nil
}
switch args[0] {
case "set-key":
if len(args) > 1 {
return &exitError{code: exitUsage, err: errors.New("license set-key takes no arguments; pass the key on stdin")}
}
return cmdLicenseSetKey(ctx, lookup, stdin, stdout, logOut)
default:
licenseUsage(logOut)
return &exitError{code: exitUsage, err: fmt.Errorf("license: unknown subcommand %q", args[0])}
}
}
// readKey reads the license key from r: trimmed, not empty, bounded.
func readKey(r io.Reader) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, maxKeyInput+1))
if err != nil {
return "", fmt.Errorf("read license key from stdin: %w", err)
}
if len(data) > maxKeyInput {
return "", &exitError{code: exitUsage, err: fmt.Errorf("input on stdin is larger than %d bytes; expected a single license key", maxKeyInput)}
}
key := string(bytes.TrimSpace(data))
switch {
case key == "":
return "", &exitError{code: exitUsage, err: errors.New("no license key on stdin")}
case len(key) > maxKeyLength:
return "", &exitError{code: exitUsage, err: fmt.Errorf("license key is longer than %d characters", maxKeyLength)}
case strings.ContainsAny(key, "\r\n"):
return "", &exitError{code: exitUsage, err: errors.New("expected a single license key, got several lines")}
}
return key, nil
}
func cmdLicenseSetKey(ctx context.Context, lookup config.LookupFunc, stdin io.Reader, stdout, logOut io.Writer) error {
if f, ok := stdin.(*os.File); ok {
if fi, err := f.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
_, _ = fmt.Fprintln(logOut, "reading the license key from stdin (finish with Ctrl-D)")
}
}
key, err := readKey(stdin)
if err != nil {
return err
}
cfg, log, err := setup(lookup, logOut)
if err != nil {
return err
}
core, err := app.Open(ctx, cfg, log, true)
if err != nil {
return err
}
defer core.Close()
st, err := core.Service.SetLicenseKey(ctx, service.SystemActor, key)
if err != nil {
return licenseCLIError(err)
}
return printLicense(stdout, st)
}
// licenseCLIError maps service errors to messages and exit codes.
func licenseCLIError(err error) error {
var se *service.Error
if !errors.As(err, &se) {
return err
}
msg := se.Message
if pc := se.Params["provider_code"]; pc != "" {
msg += " (" + pc + ")"
}
switch se.Kind {
case service.KindUnavailable:
return &exitError{code: exitLicenseSvc, err: fmt.Errorf("license key not stored: %s [%s]", msg, se.Code)}
case service.KindInvalid, service.KindConflict:
return &exitError{code: exitKeyRejected, err: fmt.Errorf("license key rejected: %s [%s]", msg, se.Code)}
}
return err
}
func printLicense(w io.Writer, st service.LicenseStatus) error {
limit := func(v *int) string {
if v == nil {
return "unlimited"
}
return strconv.Itoa(*v)
}
day := func(t *time.Time) string {
if t == nil {
return "never"
}
return t.UTC().Format(time.RFC3339)
}
features := strings.Join(st.Features, ", ")
if features == "" {
features = "-"
}
name := st.Name
if st.LicenseID != "" {
name = strings.TrimSpace(name + " (" + st.LicenseID + ")")
}
_, err := fmt.Fprintf(w, "status: %s\nreason: %s\nlicense: %s\nexpires: %s\n"+
"max agents: %s\nmax addresses: %s (in use: %d)\nfeatures: %s\n",
st.State, st.Reason, name, day(st.ExpiresAt), limit(st.Limits.MaxAgents), limit(st.Limits.MaxAddresses),
st.Usage.Addresses, features)
return err
}