From 1a0c88050926db5284dbfd2766e7705a5b51bbd4 Mon Sep 17 00:00:00 2001 From: Chris Lenz Date: Wed, 21 Oct 2026 20:22:43 +0200 Subject: [PATCH 1/5] feat: get license from voucher --- .gitignore | 1 + cmd/rtb-buddy/cmd/root.go | 30 +++++++ cmd/rtb-buddy/main.go | 5 -- pkg/cmd/rbfs/lix1_ping.go | 42 +++++----- pkg/cmd/voucher/flags.go | 10 +++ pkg/cmd/voucher/jwks.go | 73 ++++++++++++++++ pkg/cmd/voucher/license.go | 137 +++++++++++++++++++++++++++++++ pkg/cmd/voucher/request.go | 15 ++++ pkg/cmd/voucher/voucher.go | 14 ++++ pkg/httpclient/client.go | 16 +++- pkg/httpclient/client_test.go | 4 +- pkg/httpclient/option.go | 17 +++- pkg/httpclient/request_option.go | 12 +++ pkg/license/client.go | 99 ++++++++++++++++++++++ pkg/license/license.go | 68 +++++++++++++++ pkg/license/model.go | 84 +++++++++++++++++++ 16 files changed, 595 insertions(+), 32 deletions(-) create mode 100644 pkg/cmd/voucher/flags.go create mode 100644 pkg/cmd/voucher/jwks.go create mode 100644 pkg/cmd/voucher/license.go create mode 100644 pkg/cmd/voucher/request.go create mode 100644 pkg/cmd/voucher/voucher.go create mode 100644 pkg/license/client.go create mode 100644 pkg/license/license.go create mode 100644 pkg/license/model.go diff --git a/.gitignore b/.gitignore index c6abd09..5ae4e21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /rtb-buddy /.idea/ /coverage.out +/rtblab \ No newline at end of file diff --git a/cmd/rtb-buddy/cmd/root.go b/cmd/rtb-buddy/cmd/root.go index 8e37e9d..a5bb4bc 100644 --- a/cmd/rtb-buddy/cmd/root.go +++ b/cmd/rtb-buddy/cmd/root.go @@ -1,15 +1,20 @@ package cmd import ( + "fmt" + "log/slog" "os" "github.com/rtbrick/tools/cmd/rtb-buddy/config" "github.com/rtbrick/tools/pkg/cmd/apigw" "github.com/rtbrick/tools/pkg/cmd/lix1" "github.com/rtbrick/tools/pkg/cmd/rbfs" + "github.com/rtbrick/tools/pkg/cmd/voucher" "github.com/spf13/cobra" ) +var logLevel string + func NewRootCmd(silenceErrors, silenceUsage bool) *cobra.Command { cmd := &cobra.Command{ Use: "rtb-buddy", @@ -19,9 +24,34 @@ func NewRootCmd(silenceErrors, silenceUsage bool) *cobra.Command { } cmd.SetOut(os.Stdout) cmd.Version = config.VERSION + cmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)") + cmd.RegisterFlagCompletionFunc("log-level", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"debug", "info", "warn", "error"}, cobra.ShellCompDirectiveNoFileComp + }) + cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + var level slog.Level + switch logLevel { + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + return fmt.Errorf("unknown log level %q", logLevel) + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: false, + Level: level, + }))) + return nil + } cmd.AddCommand(apigw.NewApigwCmd()) cmd.AddCommand(lix1.NewLix1Cmd()) cmd.AddCommand(rbfs.NewRbfsCmd()) + cmd.AddCommand(voucher.NewVoucherCmd()) cmd.AddCommand(newVersionCmd()) return cmd } diff --git a/cmd/rtb-buddy/main.go b/cmd/rtb-buddy/main.go index fd77f6d..503869d 100644 --- a/cmd/rtb-buddy/main.go +++ b/cmd/rtb-buddy/main.go @@ -1,17 +1,12 @@ package main import ( - "log/slog" "os" "github.com/rtbrick/tools/cmd/rtb-buddy/cmd" ) func main() { - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - AddSource: false, - Level: slog.LevelDebug, - }))) if err := cmd.NewRootCmd(false, false).Execute(); err != nil { os.Exit(1) } diff --git a/pkg/cmd/rbfs/lix1_ping.go b/pkg/cmd/rbfs/lix1_ping.go index dc516fd..1ff820e 100644 --- a/pkg/cmd/rbfs/lix1_ping.go +++ b/pkg/cmd/rbfs/lix1_ping.go @@ -2,14 +2,15 @@ package rbfs import ( "bytes" - "crypto/tls" "encoding/xml" "fmt" "io" - "net/http" + "net/url" "time" "github.com/spf13/cobra" + + "github.com/rtbrick/tools/pkg/httpclient" ) // buildLix1dPingBody builds the XML PingRequest body for rbfs lix1 ping @@ -73,34 +74,29 @@ func NewLix1PingCmd() *cobra.Command { } // Build HTTP client - tlsConfig := &tls.Config{ - InsecureSkipVerify: insecure, - } - if cert != "" && certKey != "" { - // Load client certificate for mTLS - certData, err := tls.LoadX509KeyPair(cert, certKey) - if err != nil { - return fmt.Errorf("load client cert/key: %w", err) - } - tlsConfig.Certificates = append(tlsConfig.Certificates, certData) + element := path + baseURL, err := url.Parse("https://" + element + ":12322") + if err != nil { + return fmt.Errorf("parse base URL: %w", err) } - // Parse element from path (could be hostname or include more details) - element := path - url := fmt.Sprintf("https://%s:12322/X1/NE", element) + opts := []httpclient.Option{ + httpclient.WithClientCert(cert, certKey), + } + if insecure { + opts = append(opts, httpclient.WithInsecureSkipVerify()) + } + hc := httpclient.NewClient(baseURL, opts...) - req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + req, err := hc.NewRequestWithContext(cmd.Context(), "POST", "/X1/NE", + bytes.NewReader(body), + httpclient.WithHeader("Content-Type", "application/xml"), + ) if err != nil { return fmt.Errorf("create request: %w", err) } - req.Header.Set("Content-Type", "application/xml") - - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{TLSClientConfig: tlsConfig}, - } - resp, err := client.Do(req) + resp, err := hc.Do(req) if err != nil { return fmt.Errorf("do request: %w", err) } diff --git a/pkg/cmd/voucher/flags.go b/pkg/cmd/voucher/flags.go new file mode 100644 index 0000000..c80bffe --- /dev/null +++ b/pkg/cmd/voucher/flags.go @@ -0,0 +1,10 @@ +package voucher + +const ( + flagVoucher = "voucher" + flagFingerprint = "fingerprint" + flagRole = "role" + flagModel = "model" + flagImageID = "image-id" + flagOutput = "output" +) diff --git a/pkg/cmd/voucher/jwks.go b/pkg/cmd/voucher/jwks.go new file mode 100644 index 0000000..ebd7b67 --- /dev/null +++ b/pkg/cmd/voucher/jwks.go @@ -0,0 +1,73 @@ +package voucher + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "time" + + "github.com/rtbrick/tools/pkg/license" + "github.com/spf13/cobra" +) + +func NewJWKSCmd() *cobra.Command { + var voucher string + var output string + + cmd := &cobra.Command{ + Use: "jwks", + Short: "Request JWKS from a voucher", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + status, err := license.DecodeVoucher(voucher) + if err != nil { + return fmt.Errorf("failed to decode voucher: %w", err) + } + + if status.CustomClaims == nil || status.CustomClaims.LicenseServerURL == nil { + return fmt.Errorf("voucher does not contain a license server URL") + } + + ls, err := url.Parse(*status.CustomClaims.LicenseServerURL) + if err != nil { + return fmt.Errorf("failed to parse license server URL: %w", err) + } + + client := license.NewDefaultLicenseClient(voucher, ls) + + jwks, _, err := client.RequestJWKS(ctx) + if err != nil { + return fmt.Errorf("failed to request JWKS: %w", err) + } + + jwksJSON, err := json.MarshalIndent(jwks, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JWKS: %w", err) + } + + if output != "" { + if err := os.WriteFile(output, jwksJSON, 0644); err != nil { + return fmt.Errorf("failed to write JWKS to %s: %w", output, err) + } + cmd.Printf("JWKS written to %s\n", output) + } else { + cmd.Println(string(jwksJSON)) + } + + return nil + }, + } + + cmd.Flags().StringVar(&voucher, flagVoucher, "", "Voucher JWT string") + cmd.Flags().StringVar(&output, flagOutput, "", "Output path for JWKS file (only written if set)") + + if err := cmd.MarkFlagRequired(flagVoucher); err != nil { + panic(err) + } + + return cmd +} diff --git a/pkg/cmd/voucher/license.go b/pkg/cmd/voucher/license.go new file mode 100644 index 0000000..99ddf61 --- /dev/null +++ b/pkg/cmd/voucher/license.go @@ -0,0 +1,137 @@ +package voucher + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/fatih/color" + "github.com/go-jose/go-jose/v4" + "github.com/rtbrick/tools/pkg/license" + "github.com/spf13/cobra" +) + +var labelColor = color.New(color.FgCyan).SprintFunc() + +func NewLicenseCmd() *cobra.Command { + var voucher string + var fingerprint string + var role string + var model string + var imageID string + + cmd := &cobra.Command{ + Use: "license", + Short: "Request a license from a voucher", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + m, ok := license.PredefinedModels[model] + if !ok { + return fmt.Errorf("unknown model %q", model) + } + + status, err := license.DecodeVoucher(voucher) + if err != nil { + return fmt.Errorf("failed to decode voucher: %w", err) + } + + if status.CustomClaims == nil || status.CustomClaims.LicenseServerURL == nil { + return fmt.Errorf("voucher does not contain a license server URL") + } + + ls, err := url.Parse(*status.CustomClaims.LicenseServerURL) + if err != nil { + return fmt.Errorf("failed to parse license server URL: %w", err) + } + + client := license.NewDefaultLicenseClient(voucher, ls) + + jwks, licToken, _, err := requestLicense(ctx, client, &license.VoucherNodeLicenseRequest{ + Fingerprint: fingerprint, + Role: role, + PlatformChipset: m.Chipset, + PlatformModel: m.Model, + Manufacturer: m.Manufacturer, + ImageID: imageID, + }) + if err != nil { + return fmt.Errorf("failed to request license: %w", err) + } + + l, err := license.DecodeLicense(jwks, licToken) + if err != nil { + return fmt.Errorf("failed to decode license: %w", err) + } + + strVal := func(s *string) string { + if s == nil { + return "" + } + return *s + } + + cmd.Println(labelColor("License Token:"), l.Token) + cmd.Println(labelColor("Issuer:"), l.Claims.Issuer) + cmd.Println(labelColor("Subject:"), l.Claims.Subject) + cmd.Println(labelColor("Fingerprint:"), l.CustomClaims.Fingerprint) + cmd.Println(labelColor("Role:"), l.CustomClaims.Role) + cmd.Println(labelColor("Manufacturer:"), strVal(l.CustomClaims.Manufacturer)) + cmd.Println(labelColor("Chipset:"), strVal(l.CustomClaims.Chipset)) + cmd.Println(labelColor("Model:"), strVal(l.CustomClaims.Model)) + cmd.Println(labelColor("Image:"), strVal(l.CustomClaims.Image)) + cmd.Println(labelColor("Voucher UUID:"), l.CustomClaims.VoucherUUID) + + return nil + }, + } + + cmd.Flags().StringVar(&voucher, flagVoucher, "", "Voucher JWT string") + cmd.Flags().StringVar(&fingerprint, flagFingerprint, "", "Device fingerprint") + cmd.Flags().StringVar(&role, flagRole, "", "Device role") + if err := cmd.RegisterFlagCompletionFunc(flagRole, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return license.PredefinedRoles, cobra.ShellCompDirectiveNoFileComp + }); err != nil { + panic(err) + } + cmd.Flags().StringVar(&model, flagModel, "", "Hardware model (one of predefined models)") + if err := cmd.RegisterFlagCompletionFunc(flagModel, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var models []string + for m := range license.PredefinedModels { + models = append(models, m) + } + return models, cobra.ShellCompDirectiveNoFileComp + }); err != nil { + panic(err) + } + cmd.Flags().StringVar(&imageID, flagImageID, "", "Image ID (optional)") + + if err := cmd.MarkFlagRequired(flagVoucher); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired(flagFingerprint); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired(flagRole); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired(flagModel); err != nil { + panic(err) + } + + return cmd +} + +func requestLicense(ctx context.Context, client *license.LicenseClient, request *license.VoucherNodeLicenseRequest) (*jose.JSONWebKeySet, string, bool, error) { + lic, isNew, err := client.RequestLicense(ctx, request) + if err != nil { + return nil, "", false, err + } + jwks, _, err := client.RequestJWKS(ctx) + if err != nil { + return nil, "", false, err + } + return jwks, lic.Token, isNew, nil +} diff --git a/pkg/cmd/voucher/request.go b/pkg/cmd/voucher/request.go new file mode 100644 index 0000000..3770283 --- /dev/null +++ b/pkg/cmd/voucher/request.go @@ -0,0 +1,15 @@ +package voucher + +import ( + "github.com/spf13/cobra" +) + +func NewRequestCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "request", + Short: "Request a license from a voucher", + } + cmd.AddCommand(NewLicenseCmd()) + cmd.AddCommand(NewJWKSCmd()) + return cmd +} diff --git a/pkg/cmd/voucher/voucher.go b/pkg/cmd/voucher/voucher.go new file mode 100644 index 0000000..295fca3 --- /dev/null +++ b/pkg/cmd/voucher/voucher.go @@ -0,0 +1,14 @@ +package voucher + +import ( + "github.com/spf13/cobra" +) + +func NewVoucherCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "voucher", + Short: "Manage vouchers and licenses", + } + cmd.AddCommand(NewRequestCmd()) + return cmd +} diff --git a/pkg/httpclient/client.go b/pkg/httpclient/client.go index eb2aa36..024abc8 100644 --- a/pkg/httpclient/client.go +++ b/pkg/httpclient/client.go @@ -4,8 +4,11 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" + "log/slog" "net/http" + "net/http/httputil" "net/url" "strings" "time" @@ -53,7 +56,7 @@ func (c *HTTPClient) NewRequestWithContext(ctx context.Context, method, path str // newJSONRequestWithContext creates an HTTP request with a JSON body // The Content-Type and Accept headers are set to application/json -func (c *HTTPClient) newJSONRequestWithContext(ctx context.Context, method, path string, body any, opts ...RequestOption) (*http.Request, error) { +func (c *HTTPClient) NewJSONRequestWithContext(ctx context.Context, method, path string, body any, opts ...RequestOption) (*http.Request, error) { var req *http.Request var err error @@ -81,6 +84,17 @@ func (c *HTTPClient) newJSONRequestWithContext(ctx context.Context, method, path req.Header.Set(HeaderContentType, ContentTypeJSON) req.Header.Set(HeaderAccept, acceptJSON+","+acceptAny) + if slog.Default().Enabled(ctx, slog.LevelDebug) { + d, err := httputil.DumpRequest(req, true) + if err != nil { + slog.Error("Error dumping request", slog.Any("err", err)) + } else { + fmt.Println("*************") + fmt.Println("Debug request") + fmt.Println(string(d)) + fmt.Println("*************") + } + } return req, nil } diff --git a/pkg/httpclient/client_test.go b/pkg/httpclient/client_test.go index 9e00f28..6fcc757 100644 --- a/pkg/httpclient/client_test.go +++ b/pkg/httpclient/client_test.go @@ -50,7 +50,7 @@ func TestNewJSONRequestWithContext(t *testing.T) { baseURL, _ := url.Parse(ts.URL) client := NewClient(baseURL) - req, err := client.newJSONRequestWithContext(context.Background(), "POST", "/test", map[string]string{"key": "value"}) + req, err := client.NewJSONRequestWithContext(context.Background(), "POST", "/test", map[string]string{"key": "value"}) require.NoError(t, err) assert.Equal(t, "application/json", req.Header.Get("Content-Type")) @@ -129,6 +129,6 @@ func TestWithTimeout(t *testing.T) { defer ts.Close() baseURL, _ := url.Parse(ts.URL) - client := NewClient(baseURL, withTimeout(5*time.Second)) + client := NewClient(baseURL, WithTimeout(5*time.Second)) assert.Equal(t, 5*time.Second, client.client.Timeout) } diff --git a/pkg/httpclient/option.go b/pkg/httpclient/option.go index ccf2700..d333a03 100644 --- a/pkg/httpclient/option.go +++ b/pkg/httpclient/option.go @@ -1,6 +1,8 @@ package httpclient import ( + "crypto/tls" + "fmt" "net/http" "time" ) @@ -13,6 +15,19 @@ func WithInsecureSkipVerify() Option { } } -func withTimeout(d time.Duration) Option { +// WithClientCert loads a client certificate/key pair for mTLS and appends it +// to the client's TLS configuration. +func WithClientCert(certFile, keyFile string) Option { + return func(c *HTTPClient) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + panic(fmt.Sprintf("load client cert/key: %v", err)) + } + c.client.Transport.(*http.Transport).TLSClientConfig.Certificates = + append(c.client.Transport.(*http.Transport).TLSClientConfig.Certificates, cert) + } +} + +func WithTimeout(d time.Duration) Option { return func(c *HTTPClient) { c.client.Timeout = d } } diff --git a/pkg/httpclient/request_option.go b/pkg/httpclient/request_option.go index f6d6b54..841a4b9 100644 --- a/pkg/httpclient/request_option.go +++ b/pkg/httpclient/request_option.go @@ -15,7 +15,19 @@ func WithHeader(key, value string) RequestOption { r.Header.Set(key, value) } } +func WithVoucher(value string) RequestOption { + return func(r *http.Request) { + r.Header.Set("Authorization", "Voucher "+value) + } +} +func WithIfNoneMatch(etag string) RequestOption { + return func(r *http.Request) { + if etag != "" { + r.Header.Set("If-None-Match", etag) + } + } +} func withQuery(query string) RequestOption { return func(r *http.Request) { r.URL.RawQuery = query diff --git a/pkg/license/client.go b/pkg/license/client.go new file mode 100644 index 0000000..0eafda1 --- /dev/null +++ b/pkg/license/client.go @@ -0,0 +1,99 @@ +package license + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/go-jose/go-jose/v4" + "github.com/rtbrick/tools/pkg/httpclient" +) + +type LicenseClient struct { + client *httpclient.HTTPClient + voucher string + license *License + licenseETag string + jwks *jose.JSONWebKeySet + jwksETag string +} + +func (c *LicenseClient) RequestLicense(ctx context.Context, request *VoucherNodeLicenseRequest) (*License, bool, error) { + req, err := c.client.NewJSONRequestWithContext( + ctx, http.MethodPost, "/api/vouchers/issue-license", request, + httpclient.WithIfNoneMatch(c.licenseETag), + httpclient.WithVoucher(c.voucher), + ) + if err != nil { + return nil, false, err + } + + res, err := c.client.Do(req) + if err != nil { + return nil, false, err + } + + if res.StatusCode == http.StatusNotModified { + return c.license, false, nil + } + + if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusCreated { + var license License + d := json.NewDecoder(res.Body) + err = d.Decode(&license) + if err != nil { + return nil, false, err + } + + c.licenseETag = res.Header.Get("ETag") + c.license = &license + + return c.license, true, nil + } + + return nil, false, &HttpError{Message: "license request failed", StatusCode: res.StatusCode} +} + +func (c *LicenseClient) RequestJWKS(ctx context.Context) (*jose.JSONWebKeySet, bool, error) { + req, err := c.client.NewJSONRequestWithContext( + ctx, http.MethodGet, "/api/vouchers/certs", nil, + httpclient.WithIfNoneMatch(c.jwksETag), + httpclient.WithVoucher(c.voucher), + ) + if err != nil { + return nil, false, err + } + + res, err := c.client.Do(req) + if err != nil { + return nil, false, err + } + + if res.StatusCode == http.StatusNotModified { + return c.jwks, false, nil + } + + if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusCreated { + var jwks jose.JSONWebKeySet + d := json.NewDecoder(res.Body) + err = d.Decode(&jwks) + if err != nil { + return nil, false, err + } + + c.jwksETag = res.Header.Get("ETag") + c.jwks = &jwks + return c.jwks, true, nil + } + return nil, false, fmt.Errorf("license JWKS request failed (%d)", res.StatusCode) +} + +type VoucherNodeLicenseRequest struct { + Fingerprint string `json:"fingerprint"` + Role string `json:"role"` + PlatformChipset string `json:"platform_chipset"` + PlatformModel string `json:"platform_model"` + Manufacturer string `json:"manufacturer"` + ImageID string `json:"image_id"` +} diff --git a/pkg/license/license.go b/pkg/license/license.go new file mode 100644 index 0000000..d57c560 --- /dev/null +++ b/pkg/license/license.go @@ -0,0 +1,68 @@ +package license + +import ( + "errors" + "net/url" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/rtbrick/tools/pkg/httpclient" +) + +// NewDefaultLicenseClient creates a new license service client. +func NewDefaultLicenseClient(voucher string, licenseServerURL *url.URL) *LicenseClient { + c := httpclient.NewClient(licenseServerURL) + + return &LicenseClient{ + client: c, + voucher: voucher, + } +} + +func DecodeVoucher(voucher string) (*Voucher, error) { + // Decode voucher without verification + token, err := jwt.ParseSigned(voucher, []jose.SignatureAlgorithm{jose.EdDSA}) + if err != nil { + return nil, errors.Join(ErrMalformedLicense, err) + } + + var claims jwt.Claims + var customClaims VoucherCustomClaims + + err = token.UnsafeClaimsWithoutVerification(&claims, &customClaims) + if err != nil { + return nil, errors.Join(ErrInvalidLicense, err) + } + + return &Voucher{ + Token: voucher, + Claims: &claims, + CustomClaims: &customClaims, + }, nil +} +func DecodeLicense(jwks *jose.JSONWebKeySet, license string) (*License, error) { + // Decode token and verify license key + token, err := jwt.ParseSigned(license, []jose.SignatureAlgorithm{jose.EdDSA}) + if err != nil { + return nil, errors.Join(ErrMalformedLicense, err) + } + + var claims jwt.Claims + var customClaims LicenseCustomClaims + + err = token.Claims(jwks, &claims, &customClaims) + if err != nil { + return nil, errors.Join(ErrInvalidLicense, err) + } + + return &License{ + Token: license, + Claims: &claims, + CustomClaims: &customClaims, + }, nil +} + +var ( + ErrMalformedLicense = errors.New("malformed license string") + ErrInvalidLicense = errors.New("invalid license signature") +) diff --git a/pkg/license/model.go b/pkg/license/model.go new file mode 100644 index 0000000..410a491 --- /dev/null +++ b/pkg/license/model.go @@ -0,0 +1,84 @@ +package license + +import ( + "context" + "fmt" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +// LicenseServiceClient downloads node licenses from the portal. +type LicenseServiceClient interface { + // RequestLicense requests a license from the license server. The boolean indicates whether the license server issued a new license. + RequestLicense(ctx context.Context, request *VoucherNodeLicenseRequest) (*License, bool, error) + // RequestJWKS request a JWKS from the license server. The boolean indicates whether the license server issued a new JWKS. + RequestJWKS(ctx context.Context) (*jose.JSONWebKeySet, bool, error) +} + +type HttpError struct { + Message string + StatusCode int +} + +func (e *HttpError) Error() string { + return fmt.Sprintf("%s (%d)", e.Message, e.StatusCode) +} + +type Voucher struct { + Token string + Claims *jwt.Claims `json:"claims"` + CustomClaims *VoucherCustomClaims `json:"custom_claims"` +} +type VoucherCustomClaims struct { + OrganizationUUID string `json:"oid,omitempty"` + VoucherName string `json:"vnm,omitempty"` + ProductUUID string `json:"pid,omitempty"` + LicenseServerURL *string `json:"ls,omitempty"` +} +type License struct { + Token string `json:"token"` + Claims *jwt.Claims `json:"claims"` + CustomClaims *LicenseCustomClaims `json:"custom_claims"` +} + +// LicenseCustomClaims models the rtbrick custom claims encoded into the issued license JWT. +type LicenseCustomClaims struct { + Name string `json:"name,omitempty"` + Fingerprint string `json:"fingerprint"` + Role string `json:"role,omitempty"` + Manufacturer *string `json:"manufacturer,omitempty"` + Chipset *string `json:"chipset,omitempty"` + Model *string `json:"model,omitempty"` + Image *string `json:"image,omitempty"` + VoucherUUID string `json:"vid,omitempty"` + OrganizationUUID string `json:"oid,omitempty"` + PurchaseOrderUUID string `json:"poid,omitempty"` +} + +// PredefinedModels maps hardware model keys to their chipset, vendor, and manufacturer. +var PredefinedModels = map[string]struct { + Chipset string + Vendor string + Manufacturer string + Model string +}{ + //end of life + "edgecore_AS7316-26XB": {Chipset: "QAX", Vendor: "EdgeCore", Manufacturer: "accton", Model: "AS7316-26XB"}, + "edgecore_AS7535-28XB": {Chipset: "Q2A", Vendor: "EdgeCore", Manufacturer: "accton", Model: "AS7535-28XB"}, + "edgecore_AS7946-30XB": {Chipset: "Q2C", Vendor: "EdgeCore", Manufacturer: "accton", Model: "AS7946-30XB"}, + "edgecore_AS7946-74XKSB": {Chipset: "Q2C", Vendor: "EdgeCore", Manufacturer: "accton", Model: "AS7946-74XKSB"}, + "ufispace_S9510-28DC": {Chipset: "Q2A", Vendor: "ufiSpace", Manufacturer: "ufispace", Model: "S9510-28DC"}, + "ufispace_S9600-102XC": {Chipset: "Q2C", Vendor: "ufiSpace", Manufacturer: "ufispace", Model: "S9600-102XC"}, + "ufispace_S9600-32X": {Chipset: "Q2C", Vendor: "ufiSpace", Manufacturer: "ufispace", Model: "S9600-32X"}, + "ufispace_S9600-72XC": {Chipset: "Q2C", Vendor: "ufiSpace", Manufacturer: "ufispace", Model: "S9600-72XC"}, + "ufispace_S9500-22XST": {Chipset: "QAX", Vendor: "ufiSpace", Manufacturer: "ufispace", Model: "S9500-22XST"}, +} + +// PredefinedRoles lists the valid device roles. +var PredefinedRoles = []string{ + "accessleaf", + "l2bsa", + "multiservice-edge", + "spine", +} From 15b8a73aef425246c675658b8b3e9509b18e24ff Mon Sep 17 00:00:00 2001 From: Chris Lenz Date: Wed, 21 Oct 2026 20:25:22 +0200 Subject: [PATCH 2/5] feat: get license from voucher --- cmd/rtb-buddy/cmd/root.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/rtb-buddy/cmd/root.go b/cmd/rtb-buddy/cmd/root.go index a5bb4bc..c8d9f3d 100644 --- a/cmd/rtb-buddy/cmd/root.go +++ b/cmd/rtb-buddy/cmd/root.go @@ -25,9 +25,11 @@ func NewRootCmd(silenceErrors, silenceUsage bool) *cobra.Command { cmd.SetOut(os.Stdout) cmd.Version = config.VERSION cmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)") - cmd.RegisterFlagCompletionFunc("log-level", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if err := cmd.RegisterFlagCompletionFunc("log-level", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"debug", "info", "warn", "error"}, cobra.ShellCompDirectiveNoFileComp - }) + }); err != nil { + panic(err) + } cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { var level slog.Level switch logLevel { From 73d0b336d1dfb54e4d3ade11411f54aef7db4a20 Mon Sep 17 00:00:00 2001 From: Chris Lenz Date: Wed, 21 Oct 2026 20:27:15 +0200 Subject: [PATCH 3/5] feat: get license from voucher --- cmd/rtb-buddy/README.adoc | 50 ++++++++++++++++++++++++++++++++++++++ cmd/rtb-buddy/voucher.adoc | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 cmd/rtb-buddy/voucher.adoc diff --git a/cmd/rtb-buddy/README.adoc b/cmd/rtb-buddy/README.adoc index 2a42663..c39afc4 100644 --- a/cmd/rtb-buddy/README.adoc +++ b/cmd/rtb-buddy/README.adoc @@ -265,4 +265,54 @@ Verifying certificate chain against CA... Chain verification: OK ---- +=== voucher + +Manage vouchers and licenses. + +==== voucher request license + +Request a license from a voucher. + +* `--voucher` — Voucher JWT string (required) +* `--fingerprint` — Device fingerprint (required) +* `--role` — Device role (required) +* `--model` — Hardware model (required) +* `--image-id` — Image ID (optional) +* `-o, --output-jwks` — Output path for JWKS file (only written if set) + +[source,sh] +---- +# Request a license +rtb-buddy voucher request license \ + --voucher \ + --fingerprint SERIAL-123456 \ + --role multiservice-edge \ + --model "ufispace S9510-28DC" \ + --image-id my-image-id + +# Request a license and write JWKS to a file +rtb-buddy voucher request license \ + --voucher \ + --fingerprint SERIAL-123456 \ + --role multiservice-edge \ + --model "ufispace S9510-28DC" \ + -o jwks.json +---- + +==== voucher request jwks + +Request JWKS from a voucher. + +* `--voucher` — Voucher JWT string (required) +* `-o, --output-jwks` — Output path for JWKS file (only written if set) + +[source,sh] +---- +# Print JWKS to stdout +rtb-buddy voucher request jwks --voucher + +# Write JWKS to a file +rtb-buddy voucher request jwks --voucher -o jwks.json +---- + include::rbfs.adoc[] diff --git a/cmd/rtb-buddy/voucher.adoc b/cmd/rtb-buddy/voucher.adoc new file mode 100644 index 0000000..a5192fd --- /dev/null +++ b/cmd/rtb-buddy/voucher.adoc @@ -0,0 +1,49 @@ +=== voucher + +Manage vouchers and licenses. + +==== voucher request license + +Request a license from a voucher. + +* `--voucher` — Voucher JWT string (required) +* `--fingerprint` — Device fingerprint (required) +* `--role` — Device role (required) +* `--model` — Hardware model (required) +* `--image-id` — Image ID (optional) +* `-o, --output-jwks` — Output path for JWKS file (only written if set) + +[source,sh] +---- +# Request a license +rtb-buddy voucher request license \ + --voucher \ + --fingerprint SERIAL-123456 \ + --role multiservice-edge \ + --model "ufispace_S9510-28DC" \ + --image-id my-image-id + +# Request a license and write JWKS to a file +rtb-buddy voucher request license \ + --voucher \ + --fingerprint SERIAL-123456 \ + --role multiservice-edge \ + --model "ufispace_S9510-28DC" \ + -o jwks.json +---- + +==== voucher request jwks + +Request JWKS from a voucher. + +* `--voucher` — Voucher JWT string (required) +* `-o, --output-jwks` — Output path for JWKS file (only written if set) + +[source,sh] +---- +# Print JWKS to stdout +rtb-buddy voucher request jwks --voucher + +# Write JWKS to a file +rtb-buddy voucher request jwks --voucher -o jwks.json +---- From 9cca342f7680183f4d6ff6e7703db28985aaa03f Mon Sep 17 00:00:00 2001 From: Chris Lenz Date: Thu, 22 Oct 2026 18:01:12 +0200 Subject: [PATCH 4/5] feat: get license from voucher --- cmd/rtb-buddy/README.adoc | 51 +------------------------------------- cmd/rtb-buddy/voucher.adoc | 6 ++--- pkg/httpclient/headers.go | 1 + pkg/license/client.go | 4 +-- 4 files changed, 7 insertions(+), 55 deletions(-) diff --git a/cmd/rtb-buddy/README.adoc b/cmd/rtb-buddy/README.adoc index c39afc4..8a82d80 100644 --- a/cmd/rtb-buddy/README.adoc +++ b/cmd/rtb-buddy/README.adoc @@ -265,54 +265,5 @@ Verifying certificate chain against CA... Chain verification: OK ---- -=== voucher - -Manage vouchers and licenses. - -==== voucher request license - -Request a license from a voucher. - -* `--voucher` — Voucher JWT string (required) -* `--fingerprint` — Device fingerprint (required) -* `--role` — Device role (required) -* `--model` — Hardware model (required) -* `--image-id` — Image ID (optional) -* `-o, --output-jwks` — Output path for JWKS file (only written if set) - -[source,sh] ----- -# Request a license -rtb-buddy voucher request license \ - --voucher \ - --fingerprint SERIAL-123456 \ - --role multiservice-edge \ - --model "ufispace S9510-28DC" \ - --image-id my-image-id - -# Request a license and write JWKS to a file -rtb-buddy voucher request license \ - --voucher \ - --fingerprint SERIAL-123456 \ - --role multiservice-edge \ - --model "ufispace S9510-28DC" \ - -o jwks.json ----- - -==== voucher request jwks - -Request JWKS from a voucher. - -* `--voucher` — Voucher JWT string (required) -* `-o, --output-jwks` — Output path for JWKS file (only written if set) - -[source,sh] ----- -# Print JWKS to stdout -rtb-buddy voucher request jwks --voucher - -# Write JWKS to a file -rtb-buddy voucher request jwks --voucher -o jwks.json ----- - include::rbfs.adoc[] +include::voucher.adoc[] diff --git a/cmd/rtb-buddy/voucher.adoc b/cmd/rtb-buddy/voucher.adoc index a5192fd..421cdd2 100644 --- a/cmd/rtb-buddy/voucher.adoc +++ b/cmd/rtb-buddy/voucher.adoc @@ -1,10 +1,10 @@ === voucher -Manage vouchers and licenses. +Manages vouchers and licenses. ==== voucher request license -Request a license from a voucher. +Requests a license from a voucher. * `--voucher` — Voucher JWT string (required) * `--fingerprint` — Device fingerprint (required) @@ -34,7 +34,7 @@ rtb-buddy voucher request license \ ==== voucher request jwks -Request JWKS from a voucher. +Requests JWKS from a voucher. * `--voucher` — Voucher JWT string (required) * `-o, --output-jwks` — Output path for JWKS file (only written if set) diff --git a/pkg/httpclient/headers.go b/pkg/httpclient/headers.go index cca6032..48dd756 100644 --- a/pkg/httpclient/headers.go +++ b/pkg/httpclient/headers.go @@ -4,6 +4,7 @@ package httpclient const ( HeaderContentType = "Content-Type" HeaderAccept = "Accept" + HeaderEtag = "ETag" headerAuthorization = "Authorization" headerUserAgent = "User-Agent" ) diff --git a/pkg/license/client.go b/pkg/license/client.go index 0eafda1..222eb6c 100644 --- a/pkg/license/client.go +++ b/pkg/license/client.go @@ -46,7 +46,7 @@ func (c *LicenseClient) RequestLicense(ctx context.Context, request *VoucherNode return nil, false, err } - c.licenseETag = res.Header.Get("ETag") + c.licenseETag = res.Header.Get(httpclient.HeaderEtag) c.license = &license return c.license, true, nil @@ -82,7 +82,7 @@ func (c *LicenseClient) RequestJWKS(ctx context.Context) (*jose.JSONWebKeySet, b return nil, false, err } - c.jwksETag = res.Header.Get("ETag") + c.jwksETag = res.Header.Get(httpclient.HeaderEtag) c.jwks = &jwks return c.jwks, true, nil } From f787c29f15b4ee40fa1016bb9de896a387ce6bed Mon Sep 17 00:00:00 2001 From: Chris Lenz Date: Thu, 22 Oct 2026 18:13:10 +0200 Subject: [PATCH 5/5] feat: get license from voucher --- pkg/cmd/voucher/jwks.go | 2 +- pkg/cmd/voucher/license.go | 14 +++++------ pkg/license/client.go | 49 +++++++++++--------------------------- 3 files changed, 22 insertions(+), 43 deletions(-) diff --git a/pkg/cmd/voucher/jwks.go b/pkg/cmd/voucher/jwks.go index ebd7b67..a23e9ca 100644 --- a/pkg/cmd/voucher/jwks.go +++ b/pkg/cmd/voucher/jwks.go @@ -39,7 +39,7 @@ func NewJWKSCmd() *cobra.Command { client := license.NewDefaultLicenseClient(voucher, ls) - jwks, _, err := client.RequestJWKS(ctx) + jwks, err := client.RequestJWKS(ctx) if err != nil { return fmt.Errorf("failed to request JWKS: %w", err) } diff --git a/pkg/cmd/voucher/license.go b/pkg/cmd/voucher/license.go index 99ddf61..dc18873 100644 --- a/pkg/cmd/voucher/license.go +++ b/pkg/cmd/voucher/license.go @@ -49,7 +49,7 @@ func NewLicenseCmd() *cobra.Command { client := license.NewDefaultLicenseClient(voucher, ls) - jwks, licToken, _, err := requestLicense(ctx, client, &license.VoucherNodeLicenseRequest{ + jwks, licToken, err := requestLicense(ctx, client, &license.VoucherNodeLicenseRequest{ Fingerprint: fingerprint, Role: role, PlatformChipset: m.Chipset, @@ -124,14 +124,14 @@ func NewLicenseCmd() *cobra.Command { return cmd } -func requestLicense(ctx context.Context, client *license.LicenseClient, request *license.VoucherNodeLicenseRequest) (*jose.JSONWebKeySet, string, bool, error) { - lic, isNew, err := client.RequestLicense(ctx, request) +func requestLicense(ctx context.Context, client *license.LicenseClient, request *license.VoucherNodeLicenseRequest) (*jose.JSONWebKeySet, string, error) { + lic, err := client.RequestLicense(ctx, request) if err != nil { - return nil, "", false, err + return nil, "", err } - jwks, _, err := client.RequestJWKS(ctx) + jwks, err := client.RequestJWKS(ctx) if err != nil { - return nil, "", false, err + return nil, "", err } - return jwks, lic.Token, isNew, nil + return jwks, lic.Token, nil } diff --git a/pkg/license/client.go b/pkg/license/client.go index 222eb6c..4199e85 100644 --- a/pkg/license/client.go +++ b/pkg/license/client.go @@ -11,31 +11,22 @@ import ( ) type LicenseClient struct { - client *httpclient.HTTPClient - voucher string - license *License - licenseETag string - jwks *jose.JSONWebKeySet - jwksETag string + client *httpclient.HTTPClient + voucher string } -func (c *LicenseClient) RequestLicense(ctx context.Context, request *VoucherNodeLicenseRequest) (*License, bool, error) { +func (c *LicenseClient) RequestLicense(ctx context.Context, request *VoucherNodeLicenseRequest) (*License, error) { req, err := c.client.NewJSONRequestWithContext( ctx, http.MethodPost, "/api/vouchers/issue-license", request, - httpclient.WithIfNoneMatch(c.licenseETag), httpclient.WithVoucher(c.voucher), ) if err != nil { - return nil, false, err + return nil, err } res, err := c.client.Do(req) if err != nil { - return nil, false, err - } - - if res.StatusCode == http.StatusNotModified { - return c.license, false, nil + return nil, err } if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusCreated { @@ -43,35 +34,25 @@ func (c *LicenseClient) RequestLicense(ctx context.Context, request *VoucherNode d := json.NewDecoder(res.Body) err = d.Decode(&license) if err != nil { - return nil, false, err + return nil, err } - - c.licenseETag = res.Header.Get(httpclient.HeaderEtag) - c.license = &license - - return c.license, true, nil + return &license, nil } - - return nil, false, &HttpError{Message: "license request failed", StatusCode: res.StatusCode} + return nil, &HttpError{Message: "license request failed", StatusCode: res.StatusCode} } -func (c *LicenseClient) RequestJWKS(ctx context.Context) (*jose.JSONWebKeySet, bool, error) { +func (c *LicenseClient) RequestJWKS(ctx context.Context) (*jose.JSONWebKeySet, error) { req, err := c.client.NewJSONRequestWithContext( ctx, http.MethodGet, "/api/vouchers/certs", nil, - httpclient.WithIfNoneMatch(c.jwksETag), httpclient.WithVoucher(c.voucher), ) if err != nil { - return nil, false, err + return nil, err } res, err := c.client.Do(req) if err != nil { - return nil, false, err - } - - if res.StatusCode == http.StatusNotModified { - return c.jwks, false, nil + return nil, err } if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusCreated { @@ -79,14 +60,12 @@ func (c *LicenseClient) RequestJWKS(ctx context.Context) (*jose.JSONWebKeySet, b d := json.NewDecoder(res.Body) err = d.Decode(&jwks) if err != nil { - return nil, false, err + return nil, err } - c.jwksETag = res.Header.Get(httpclient.HeaderEtag) - c.jwks = &jwks - return c.jwks, true, nil + return &jwks, nil } - return nil, false, fmt.Errorf("license JWKS request failed (%d)", res.StatusCode) + return nil, fmt.Errorf("license JWKS request failed (%d)", res.StatusCode) } type VoucherNodeLicenseRequest struct {