From 6fe0699cdc139bff41353be2abfda20c5ff29645 Mon Sep 17 00:00:00 2001 From: reubenmiller Date: Sun, 15 Jun 2025 21:02:24 +0200 Subject: [PATCH 1/3] add countdown progress bar --- pkg/progressbar/countdown.go | 175 +++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 pkg/progressbar/countdown.go diff --git a/pkg/progressbar/countdown.go b/pkg/progressbar/countdown.go new file mode 100644 index 000000000..30cb067ee --- /dev/null +++ b/pkg/progressbar/countdown.go @@ -0,0 +1,175 @@ +package progressbar + +import ( + "fmt" + "io" + "sync" + "time" + + "github.com/vbauerster/mpb/v6" + "github.com/vbauerster/mpb/v6/decor" +) + +// CountdownProgressBar progress bar to show a countdown +type CountdownProgressBar struct { + mu sync.Mutex + p *mpb.Progress + spinner *mpb.Bar + TaskName string + StartedAt time.Time + ExpiresAt time.Time + Duration time.Duration + enabled bool + started bool + refreshRate time.Duration + w io.Writer +} + +// NewCountdownProgressBar create a new progress bar to display countdown timer +func NewCountdownProgressBar(w io.Writer, duration time.Duration, name string, enable bool) *CountdownProgressBar { + var p *mpb.Progress + refreshRate := 120 * time.Millisecond + if enable { + p = mpb.New( + mpb.ContainerOptional( + mpb.WithRefreshRate(refreshRate), true), + mpb.WithOutput(w), + ) + } + + if name != "" { + name = "sending request" + } + + return &CountdownProgressBar{ + p: p, + TaskName: name, + Duration: duration, + enabled: enable, + refreshRate: refreshRate, + w: w, + } +} + +// IsEnabled check if the progress bar is enabled or not. +func (p *CountdownProgressBar) IsEnabled() bool { + return p.enabled +} + +func (p *CountdownProgressBar) UpdateValue(l string) { + p.mu.Lock() + defer p.mu.Unlock() + completed := time.Since(p.StartedAt).Seconds() + p.spinner.SetCurrent(int64(completed)) +} + +// IsRunning check if the progress bar is running or not (aka. has started) +func (p *CountdownProgressBar) IsRunning() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.started +} + +// RefreshRate returns the configured refresh rate of the progress bar +func (p *CountdownProgressBar) RefreshRate() time.Duration { + return p.refreshRate +} + +// Start start displaying the progress bar +func (p *CountdownProgressBar) Start() { + + if !p.IsEnabled() { + return + } + + p.mu.Lock() + defer p.mu.Unlock() + + if p.started { + return + } + + // add new line before progress for a cleaner look + fmt.Fprintln(p.w, "") + p.StartedAt = time.Now() + p.ExpiresAt = p.StartedAt.Add(p.Duration) + + overviewLabel := "(expires at: " + p.ExpiresAt.Format(time.RFC3339) + ")" + spinner := p.p.AddSpinner(int64(p.Duration.Seconds()), mpb.SpinnerOnRight, + mpb.PrependDecorators( + decor.Name("remaining", decor.WC{W: len("remaining") + 1, C: decor.DidentRight}), + Remaining(p.ExpiresAt, decor.ET_STYLE_MMSS, decor.WC{W: 8, C: decor.DidentRight}), + decor.Name(overviewLabel, decor.WC{W: len(overviewLabel) + 1, C: decor.DidentRight}), + ), + ) + p.spinner = spinner + p.started = true +} + +// Wait waits for the progress bar to finish +func (p *CountdownProgressBar) Wait() { + if p.IsEnabled() && p.IsRunning() { + p.Wait() + } +} + +// Remaining decorator. It's wrapper of NewRemaining. +// +// `style` one of [ET_STYLE_GO|ET_STYLE_HHMMSS|ET_STYLE_HHMM|ET_STYLE_MMSS] +// +// `wcc` optional WC config +func Remaining(endTime time.Time, style decor.TimeStyle, wcc ...decor.WC) decor.Decorator { + return NewRemaining(style, endTime, wcc...) +} + +// NewRemaining returns remaining time decorator. +// +// `style` one of [ET_STYLE_GO|ET_STYLE_HHMMSS|ET_STYLE_HHMM|ET_STYLE_MMSS] +// +// `endTime` end time +// +// `wcc` optional WC config +func NewRemaining(style decor.TimeStyle, endTime time.Time, wcc ...decor.WC) decor.Decorator { + var msg string + producer := chooseTimeProducer(style) + fn := func(s decor.Statistics) string { + if !s.Completed { + msg = producer(max(time.Until(endTime), 0)) + } + return msg + } + return decor.Any(fn, wcc...) +} + +func chooseTimeProducer(style decor.TimeStyle) func(time.Duration) string { + switch style { + case decor.ET_STYLE_HHMMSS: + return func(remaining time.Duration) string { + hours := int64(remaining/time.Hour) % 60 + minutes := int64(remaining/time.Minute) % 60 + seconds := int64(remaining/time.Second) % 60 + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) + } + case decor.ET_STYLE_HHMM: + return func(remaining time.Duration) string { + hours := int64(remaining/time.Hour) % 60 + minutes := int64(remaining/time.Minute) % 60 + return fmt.Sprintf("%02d:%02d", hours, minutes) + } + case decor.ET_STYLE_MMSS: + return func(remaining time.Duration) string { + hours := int64(remaining/time.Hour) % 60 + minutes := int64(remaining/time.Minute) % 60 + seconds := int64(remaining/time.Second) % 60 + if hours > 0 { + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) + } + return fmt.Sprintf("%02d:%02d", minutes, seconds) + } + default: + return func(remaining time.Duration) string { + // strip off nanoseconds + return ((remaining / time.Second) * time.Second).String() + } + } +} From 8561d6f11b5776d99b885bb79b6139142cf8e017 Mon Sep 17 00:00:00 2001 From: reubenmiller Date: Sun, 15 Jun 2025 21:02:33 +0200 Subject: [PATCH 2/3] add device enrollment command --- .gitignore | 5 + pkg/cmd/devices/enroll/enroll.manual.go | 481 ++++++++++++++++++++++++ pkg/cmd/root/root.go | 2 + pkg/flags/getters.go | 22 ++ 4 files changed, 510 insertions(+) create mode 100644 pkg/cmd/devices/enroll/enroll.manual.go diff --git a/.gitignore b/.gitignore index f4c046b8a..5192e7f77 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,8 @@ docker/c8y.linux # Linting typos*.txt + +# device certs +*.crt +*.key +*.csr diff --git a/pkg/cmd/devices/enroll/enroll.manual.go b/pkg/cmd/devices/enroll/enroll.manual.go new file mode 100644 index 000000000..9f5220b5d --- /dev/null +++ b/pkg/cmd/devices/enroll/enroll.manual.go @@ -0,0 +1,481 @@ +package enroll + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + + "os" + "time" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/subcommand" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmderrors" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmdutil" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/completion" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/flags" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/iterator" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/mapbuilder" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/progressbar" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/request" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/worker" + "github.com/reubenmiller/go-c8y/pkg/c8y" + "github.com/reubenmiller/go-c8y/pkg/certutil" + "github.com/spf13/cobra" + "github.com/tidwall/gjson" +) + +// DeviceEnrollCmd command +type DeviceEnrollCmd struct { + *subcommand.SubCommand + + factory *cmdutil.Factory +} + +// NewDeviceEnrollCmd enrolls a device with Cumulocity using the Certificate Authority feature +func NewDeviceEnrollCmd(f *cmdutil.Factory) *DeviceEnrollCmd { + ccmd := &DeviceEnrollCmd{ + factory: f, + } + cmd := &cobra.Command{ + Use: "enroll", + Short: "Enroll a device using the Cumulocity Certificate Authority", + Long: heredoc.Doc(` + Register a device using the Cumulocity Certificate Authority which repeatedly tries to download the + device's certificate by submitting a Certificate Signing Request via the EST protocol. + + The registration url and QR code is printed on the console to enable users to register the device + via a web browser. + + This feature requires the private preview feature toggle, "certificate-authority" + `), + Example: heredoc.Doc(` + $ c8y devices enroll --id "ASDF098SD1J10912UD92JDLCNCU8" + Enroll a new device with a randomized one-time password + + $ c8y devices enroll --id "ASDF098SD1J10912UD92JDLCNCU8" --one-time-password "RqzwJeTusABlk4)KmtIc" + Enroll a new device and provide the one-time-password to be used for enrollment + + $ c8y devices enroll --id "ASDF098SD1J10912UD92JDLCNCU8" --host iot.latest.stage.c8y.io + Enroll a new device and specify a host name so a session does not need to be set + + $ c8y devices enroll --id "ASDF098SD1J10912UD92JDLCNCU8" --key myname.key --cert myname.crt + Enroll a new device and specify the names of the private key and public certificate to use + + $ c8y util repeat 3 | c8y devices enroll --template "{id: 'device' + input.index}" + Enroll 2 devices and create unique private key and certificate per device + `), + PreRunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + RunE: ccmd.RunE, + } + + cmdutil.DisableEncryptionCheck(cmd) + cmd.SilenceUsage = true + + cmd.Flags().String("id", "", "Device identifier. Max: 1000 characters. E.g. IMEI (required) (accepts pipeline)") + cmd.Flags().String("type", "", "Device type to register (only works when pre-registering the device)") + cmd.Flags().String("one-time-password", "", "One Time Password used for initial enrollment. Leave blank for a randomly generated password") + cmd.Flags().Duration("retry-every", 5*time.Second, "Polling interval to try to download the device certificate") + + cmd.Flags().Bool("overwrite", false, "Overwrite any existing device key and certificate") + cmd.Flags().String("mode", "", "Registration mode") + cmd.Flags().String("key", "", "Device's private certificate. If it does not exist it will be created") + cmd.Flags().String("cert", "", "Path to write the downloaded certificate to") + cmd.Flags().String("csr", "", "Use the given certificate signing request instead generating one") + cmd.Flags().String("host", "", "Custom Cumulocity host") + + completion.WithOptions( + cmd, + completion.WithValidateSet("mode", "auto\tTry pre-registering the device if credentials are found", "manual\tForce manual registration via the registration url/QR Code"), + ) + + flags.WithOptions( + cmd, + flags.WithProcessingMode(), + flags.WithData(), + f.WithTemplateFlag(cmd), + flags.WithExtendedPipelineSupport("id", "id", true, "externalId", "name", "id"), + + // Don't require prompts + flags.WithSemanticMethod("GET"), + ) + + // Required flags + + cmdutil.DisableAuthCheck(cmd) + ccmd.SubCommand = subcommand.NewSubCommand(cmd) + + return ccmd +} + +// RunE executes the command +func (n *DeviceEnrollCmd) RunE(cmd *cobra.Command, args []string) error { + cfg, err := n.factory.Config() + if err != nil { + return err + } + + llog, err := n.factory.Logger() + if err != nil { + return err + } + _ = llog + + c8yclient, err := n.factory.Client() + if err != nil { + return err + } + + consol, err := n.factory.Console() + if err != nil { + return err + } + + inputIterators, err := cmdutil.NewRequestInputIterators(cmd, cfg) + if err != nil { + return err + } + + // body + body := mapbuilder.NewInitializedMapBuilder(true) + err = flags.WithBody( + cmd, + body, + inputIterators, + flags.WithStringValue("id"), + flags.WithStringValue("host"), + flags.WithStringValue("key"), + flags.WithStringValue("cert"), + flags.WithStringValue("csr"), + flags.WithStringValue("type"), + flags.WithStringValue("mode"), + flags.WithBoolValue("overwrite"), + flags.WithStringValue("one-time-password"), + flags.WithDuration("retry-every"), + cmdutil.WithTemplateValue(n.factory), + flags.WithTemplateVariablesValue(), + ) + + if err != nil { + return cmderrors.NewUserError(err) + } + + var iter iterator.Iterator + if inputIterators.Total > 0 { + iter = mapbuilder.NewMapBuilderIterator(body) + } else { + iter = iterator.NewBoundIterator(mapbuilder.NewMapBuilderIterator(body), 1) + } + + commonOptions, err := cfg.GetOutputCommonOptions(cmd) + if err != nil { + return err + } + commonOptions.DisableResultPropertyDetection() + + showUserMessage := func(format string, a ...any) { + cfg.Logger.Infof(format, a...) + } + + prog := progressbar.NewCountdownProgressBar(n.factory.IOStreams.ErrOut, cfg.RequestTimeout(), "Enroll", true) + + return n.factory.RunWithGenericWorkers(cmd, inputIterators, iter, func(j worker.Job) (any, error) { + options := gjson.ParseBytes(j.Value.([]byte)) + + deviceID := options.Get("id").String() + if cmd.Flags().Changed("id") { + if v, err := cmd.Flags().GetString("id"); err == nil { + deviceID = v + } + } + + host := options.Get("host").String() + deviceType := options.Get("type").String() + mode := options.Get("mode").String() + if mode == "" { + mode = "auto" + } + overwrite := options.Get("overwrite").Bool() + keyFile := options.Get("key").String() + csrFile := options.Get("csr").String() + certFile := options.Get("cert").String() + oneTimePassword := options.Get("one-time-password").String() + + if keyFile == "" { + keyFile = fmt.Sprintf("%s.key", deviceID) + } + if certFile == "" { + certFile = fmt.Sprintf("%s.crt", deviceID) + } + + if oneTimePassword == "" { + if v, err := c8yclient.DeviceEnrollment.GenerateOneTimePassword(); err == nil { + oneTimePassword = v + } + } + + if host == "" { + host = c8yclient.GetHostname() + } + + if host == "" { + return "", fmt.Errorf("host is not set") + } + + // TODO: Allow users to change the host name + if err := c8yclient.SetBaseURL(host); err != nil { + return "", err + } + + clientHasCredentials := c8yclient.GetHostname() != "" && (c8yclient.Token != "" || (c8yclient.Username != "" && c8yclient.Password != "")) + preRegister := (mode == "auto" && clientHasCredentials) + + if clientHasCredentials { + if resp, err := c8yclient.DeviceCredentials.Delete(context.Background(), deviceID); err != nil { + if !resp.IsDryRun() && (resp.StatusCode() != 401 && resp.StatusCode() != 403 && resp.StatusCode() != 404) { + cfg.Logger.Infof("Could not remove existing device registration request. externalID=%s", deviceID) + } + } else { + cfg.Logger.Infof("Removed existing device registration request. externalID=%s", deviceID) + } + } + + if preRegister { + // Check if the correct credentials are available or not + buf := bytes.NewBufferString("") + c8y.BulkRegistrationRecordWriter(buf, + c8y.BulkRegistrationRecord{ + ID: deviceID, + Name: deviceID, + Type: deviceType, + AuthType: c8y.BulkRegistrationAuthTypeCertificates, + EnrollmentOTP: oneTimePassword, + IsAgent: true, + }, + ) + if _, resp, err := c8yclient.DeviceCredentials.CreateBulk(context.Background(), buf); err != nil { + preRegister = false + if resp != nil { + if resp.StatusCode() != 401 && resp.StatusCode() != 403 && resp.StatusCode() != 404 { + cfg.Logger.Warnf("Could not pre-register device. statusCode=%s, error=%s", resp.Status(), err) + } + } else { + cfg.Logger.Warnf("Could not pre-register device. error=%s", err) + } + } + } + + timeout := cfg.RequestTimeout() + retryEvery := time.Duration(options.Get("retry-every").Int()) + + // Create client that does not use any authentication + client := c8y.NewClientFromOptions(nil, c8y.ClientOptions{ + BaseURL: host, + Realtime: false, + ShowSensitive: true, + }) + + client.SetRequestOptions(c8y.DefaultRequestOptions{ + DryRun: cfg.DryRun(), + DryRunHandler: func(options *c8y.RequestOptions, req *http.Request) { + handler := &request.RequestHandler{ + IsTerminal: n.factory.IOStreams.IsStdoutTTY(), + IO: n.factory.IOStreams, + Client: client, + Config: cfg, + Logger: llog, + Console: consol, + HideSensitive: func(c *c8y.Client, s string) string { + return s + }, + } + handler.DryRunHandler(n.factory.IOStreams, options, req) + }, + }) + + if overwrite { + cfg.Logger.Info("Removing any existing private key, or certificate") + if err := os.Remove(keyFile); err != nil { + if !errors.Is(err, os.ErrNotExist) { + cfg.Logger.Warnf("Failed to remove private key. file=%s, error=%s", keyFile, err) + } + } + if err := os.Remove(certFile); err != nil { + if !errors.Is(err, os.ErrNotExist) { + cfg.Logger.Warnf("Failed to remove certificate. file=%s, error=%s", certFile, err) + } + } + } + + // Create private key + keyPem, keyWasGenerated, err := certutil.LoadOrGenerateKeyFile(keyFile) + if err != nil { + return nil, fmt.Errorf("failed to load or create private key. %w", err) + } + if keyWasGenerated { + cfg.Logger.Infof("Created a new private key. file=%s", keyFile) + } else { + cfg.Logger.Infof("Loaded an existing private key. file=%s", keyFile) + } + + key, err := certutil.ParsePrivateKeyPEM(keyPem) + if err != nil { + return nil, fmt.Errorf("failed to parse private key. %w", err) + } + + wasExisting := true + + var cert *x509.Certificate + if _, err := os.Stat(certFile); overwrite || errors.Is(err, os.ErrNotExist) { + + showUserMessage("\nšŸ“£ Starting device enrollment: externalID=%s\n", deviceID) + + // Create/Load CSR + csr, err := LoadCertificateSigningRequest( + LoadCSRFromFile(csrFile), + GenerateCSRFromKey(client, deviceID, key), + ) + if err != nil { + return nil, fmt.Errorf("failed to create certificate signing request. %w", err) + } + + ctx := context.Background() + + initDelay := 2 * time.Second + if cfg.DryRun() || preRegister { + initDelay = 0 + } + + // Enroll device + if !preRegister { + prog.Start() + } + + result := <-client.DeviceEnrollment.PollEnroll(ctx, c8y.DeviceEnrollmentOption{ + ExternalID: deviceID, + OneTimePassword: oneTimePassword, // Generate random one-time password if empty + + // Initial delay before the first download attempt + InitDelay: initDelay, + + // Check every 5 seconds + Interval: retryEvery, + + // Give up after 10 minutes + Timeout: timeout, + + // Print enrollment information + Banner: &c8y.DeviceEnrollmentBannerOptions{ + Enable: !preRegister, + ShowQRCode: true, + ShowURL: true, + }, + + CertificateSigningRequest: csr, + }) + if result.Err != nil { + showUserMessage("🚫 Failed to download the device's certificate\n") + return nil, result.Err + } + + if result.Certificate != nil { + showUserMessage("āœ… Successfully downloaded the device's certificate. key=%s, cert=%s\n", keyFile, certFile) + cert = result.Certificate + certPEM := certutil.MarshalCertificateToPEM(cert.Raw) + os.WriteFile(certFile, certPEM, 0644) + } + wasExisting = false + } else { + certPEM, err := os.ReadFile(certFile) + if err != nil { + return nil, fmt.Errorf("failed to read certificate file. %w", err) + } + + cert, err = certutil.ParseCertificatePEM(certPEM) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate. %w", err) + } + showUserMessage("\nšŸ“£ Using existing device certificate: externalID=%s, key=%s, cert=%s\n", cert.Subject.CommonName, keyFile, certFile) + } + + result := EnrollmentResult{ + Certificate: certFile, + Key: keyFile, + ID: deviceID, + DeviceType: deviceType, + AlreadyRegistered: wasExisting, + } + + outB, outErr := json.Marshal(result) + if outErr != nil { + return nil, outErr + } + + n.factory.WriteOutput(outB, cmdutil.OutputContext{ + Input: j.Input, + }, &commonOptions) + + return nil, nil + }) +} + +type EnrollmentResult struct { + Certificate string `json:"certificate,omitempty"` + Key string `json:"key,omitempty"` + ID string `json:"id,omitempty"` + DeviceType string `json:"deviceType,omitempty"` + AlreadyRegistered bool `json:"alreadyRegistered"` +} + +type CSRSource func() (*x509.CertificateRequest, bool, error) + +func LoadCertificateSigningRequest(opts ...CSRSource) (*x509.CertificateRequest, error) { + for _, opt := range opts { + csr, ok, err := opt() + if ok { + return csr, err + } + if err != nil { + return nil, err + } + } + return nil, fmt.Errorf("no csr was generated") +} + +func LoadCSRFromFile(path string) CSRSource { + return func() (*x509.CertificateRequest, bool, error) { + if path == "" { + return nil, false, nil + } + contents, err := os.ReadFile(path) + if err != nil { + return nil, false, err + } + block, _ := pem.Decode(contents) + if block == nil { + return nil, false, err + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + return nil, false, err + } + return csr, true, nil + } +} + +func GenerateCSRFromKey(client *c8y.Client, deviceID string, key any) CSRSource { + return func() (*x509.CertificateRequest, bool, error) { + csr, err := client.DeviceEnrollment.CreateCertificateSigningRequest(deviceID, key) + if err != nil { + return nil, false, fmt.Errorf("failed to create certificate signing request. %w", err) + } + return csr, true, nil + } +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 126d3415a..6fe11bc93 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -49,6 +49,7 @@ import ( devicesAssertCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/devices/assert" devicesAvailabilityCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/devices/availability" devicesChildrenCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/devices/children" + deviceEnrollCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/devices/enroll" deviceServicesCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/devices/services" deviceStatisticsCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/devices/statistics" deviceUserCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/devices/user" @@ -411,6 +412,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *CmdRoot { devices.AddCommand(deviceStatisticsCmd.NewSubCommand(f).GetCommand()) devices.AddCommand(deviceUserCmd.NewSubCommand(f).GetCommand()) devices.AddCommand(deviceServicesCmd.NewSubCommand(f).GetCommand()) + devices.AddCommand(deviceEnrollCmd.NewDeviceEnrollCmd(f).GetCommand()) cmd.AddCommand(devices) // devicegroups diff --git a/pkg/flags/getters.go b/pkg/flags/getters.go index 915b8e8f6..7a3170a98 100644 --- a/pkg/flags/getters.go +++ b/pkg/flags/getters.go @@ -709,6 +709,28 @@ func WithFloatValue(opts ...string) GetOption { } } +// WithDuration adds a time.Duration value from cli arguments +func WithDuration(opts ...string) GetOption { + return func(cmd *cobra.Command, inputIterators *RequestInputIterators) (string, interface{}, error) { + src, dst, format := UnpackGetterOptions("", opts...) + + if inputIterators != nil { + if inputIterators.PipeOptions.Name == src { + inputIterators.PipeOptions.Format = format + return WithPipelineIterator(inputIterators.PipeOptions)(cmd, inputIterators) + } + } + + // Note: only return if value has changed + if !cmd.Flags().Changed(src) { + return "", "", nil + } + + value, err := cmd.Flags().GetDuration(src) + return dst, value, err + } +} + // WithRelativeTimestamp adds a timestamp (string) value from cli arguments func WithRelativeTimestamp(opts ...string) GetOption { return NewTimestampFromRelative(false, false, opts...) From 0ec4880d319b3b870e52a63c29ea7347b2147cd1 Mon Sep 17 00:00:00 2001 From: reubenmiller Date: Tue, 7 Oct 2025 06:55:12 +0200 Subject: [PATCH 3/3] add example using certificates to connect to Cumulocity --- pkg/cmd/devices/enroll/enroll.manual.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/cmd/devices/enroll/enroll.manual.go b/pkg/cmd/devices/enroll/enroll.manual.go index 9f5220b5d..202f092bd 100644 --- a/pkg/cmd/devices/enroll/enroll.manual.go +++ b/pkg/cmd/devices/enroll/enroll.manual.go @@ -69,6 +69,12 @@ func NewDeviceEnrollCmd(f *cmdutil.Factory) *DeviceEnrollCmd { $ c8y util repeat 3 | c8y devices enroll --template "{id: 'device' + input.index}" Enroll 2 devices and create unique private key and certificate per device + + $ DEVICE_ID=example + $ c8y devices enroll --id "$DEVICE_ID" + $ mosquitto_sub --key "${DEVICE_ID}.key" --cert "${DEVICE_ID}.crt" -t 's/ds' -i "$DEVICE_ID" -h $C8Y_DOMAIN -p 8883 --cafile "$(brew --prefix)/etc/ca-certificates/cert.pem" --debug + $ mosquitto_sub --key "${DEVICE_ID}.key" --cert "${DEVICE_ID}.crt" --cafile "$(brew --prefix)/etc/ca-certificates/cert.pem" -i "$DEVICE_ID" -h $C8Y_DOMAIN -p 9883 -t 'custom/topic' --debug + Enroll a device and use the certificate to connect to Cumulocity via MQTT (with mosquitto_sub) `), PreRunE: func(cmd *cobra.Command, args []string) error { return nil