package services import ( "context" "errors" "fmt" "net/http" "time" "github.com/gin-gonic/gin" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" cmApi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" cmMeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" api "edge-infra.dev/pkg/edge/device-registrar/api/v1alpha1" "edge-infra.dev/pkg/edge/device-registrar/config" clientApi "edge-infra.dev/pkg/edge/iam/api/v1alpha1" ) // swagger:model DeviceConnectResponse type DeviceConnectResponse struct { Applications []ApplicationResponse `json:"applications"` DeviceAuth DeviceAuth `json:"deviceAuth,omitempty"` HostMapping HostMapping `json:"hostMapping"` } // swagger:model DeviceConnectRequest type DeviceConnectRequest struct { Device api.ExternalDeviceSpec `json:"device" binding:"required"` ApplicationIDs []string `json:"applicationIDs" binding:"required,min=1"` } // ApplicationResponse contains external application details // swagger:model ApplicationResponse type ApplicationResponse struct { // example: 1234567-e89b-12d3-a456-426614174000 ID string `json:"id"` // example: connected-associates Name string `json:"name"` // Config EdgeIDClient Config EdgeIDClient `json:"config"` } // EdgeIDClient contains the EdgeID client details // swagger:model EdgeIDClient type EdgeIDClient struct { // example: e00a1c4b-2bdc-450b-8fc6-1234567890 ClientID string `json:"clientID"` // example: bnTV~gAZj.abcdefghi~ks4wE ClientSecret string `json:"clientSecret"` } // DeviceAuth contains the device authentication details // swagger:model DeviceAuth type DeviceAuth struct { Crt []byte `json:"tls.crt,omitempty"` Key []byte `json:"tls.key,omitempty"` } var errDeviceBindingNotFound = errors.New("invalid activation code") var errActivationCodeNotFound = errors.New("activationCode is required") var errActivationCodeExpired = errors.New("activation code has expired") var errActivationCodeUsed = errors.New("activation code has already been used") // swagger:route POST /connect core ConnectDevice // Connect a device to the system // // Connect Device API initiates the connection of a device to the system. // // responses: // // 200: DeviceConnectResponse Success // 400: description:Bad Request // 500: description:Internal Server Error func ConnectDevice(c *gin.Context) { req, err := checkRequest(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } k8sClient, ctx, cancel := config.GetClientandContext(c) defer cancel() // verify the deviceBinding deviceBinding, err := retrieveDeviceRegistration(ctx, k8sClient, c) if err == errActivationCodeNotFound { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } else if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } if err := register(ctx, k8sClient, c, req, deviceBinding); err != nil { return } // grab the device binding devb := &api.DeviceBinding{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: deviceBinding.Name, Namespace: deviceBinding.Namespace, }, devb); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get device binding: " + err.Error()}) return } if err := bootstrap(ctx, k8sClient, c, devb); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } func register(ctx context.Context, k8sClient client.Client, c *gin.Context, req DeviceConnectRequest, deviceBinding *api.DeviceBinding) error { // 1. get/create the device device, applicationSubjects, extApps, err := getDeviceAndAppSubjects(ctx, k8sClient, c, req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return err } // step 3: update DeviceBinding err = updateDeviceBinding(ctx, k8sClient, device, deviceBinding.GetName(), deviceBinding.GetNamespace(), applicationSubjects) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update device binding: " + err.Error()}) return err } // step 4: create client cert storeID := "" if storeID, err = config.GetStoreID(ctx, k8sClient); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve edge-info: " + err.Error()}) return err } cert := createCert(device, storeID) if err := k8sClient.Create(ctx, cert); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create client certificate: " + err.Error()}) return err } // step 5: loop through and create EdgeID client for _, extApp := range extApps { client := createClient(device, extApp, deviceBinding) // override the secret name to make it unique per registration client.Spec.SecretName = device.GetName() + "-" + extApp.GetName() + "-secret" if err := k8sClient.Create(ctx, client); err != nil && !k8serrors.IsAlreadyExists(err) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edgeID client: " + err.Error()}) return err } _, err = waitForSecret(ctx, k8sClient, device.GetNamespace(), client.Spec.SecretName) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to wait for client secret: " + err.Error()}) return err } } return nil } func bootstrap(ctx context.Context, k8sClient client.Client, c *gin.Context, devb *api.DeviceBinding) error { applicationResponse, err := getApplicationResponse(ctx, k8sClient, devb) if err != nil { return fmt.Errorf("failed to find EdgeID client secret: %w", err) } vip, err := config.GetVIP(ctx, k8sClient) if err != nil { return fmt.Errorf("failed to retrieve VIP address: %w", err) } secretName := devb.Spec.Device.Name + "-cert" deviceAuthSecret, err := waitForSecret(ctx, k8sClient, "emissary", secretName) if err != nil { return fmt.Errorf("failed to wait for device auth secret, %w", err) } resp := DeviceConnectResponse{ Applications: applicationResponse, HostMapping: HostMapping{ Host: config.Host, VIP: vip, }, DeviceAuth: DeviceAuth{ Crt: deviceAuthSecret.Data["tls.crt"], Key: deviceAuthSecret.Data["tls.key"], }, } c.JSON(http.StatusOK, resp) return nil } func updateDeviceBinding(ctx context.Context, k8sClient client.Client, device *api.ExternalDevice, deviceBindingName string, namespace string, applicationSubjects []api.ApplicationSubject) error { // Step 1: Retrieve the existing deviceBinding deviceBinding := &api.DeviceBinding{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: deviceBindingName, Namespace: namespace, }, deviceBinding); err != nil { return fmt.Errorf("failed to get device binding: %w", err) } // Step 2: Update the OwnerReferences deviceBinding.ObjectMeta.OwnerReferences = []metav1.OwnerReference{ { APIVersion: api.GroupVersion.String(), Kind: "ExternalDevice", Name: device.GetName(), UID: device.GetUID(), }, } // Step 3: Update the fields deviceBinding.Spec.Device = api.DeviceSubject{ APIGroup: api.GroupVersion.Group, Kind: "ExternalDevice", Name: device.GetName(), } deviceBinding.Spec.Applications = applicationSubjects // Step 4: Update the deviceBinding object if err := k8sClient.Update(ctx, deviceBinding); err != nil { return fmt.Errorf("failed to update device binding: %w", err) } deviceBinding.Status.CodeUsed = true if err := k8sClient.Status().Update(ctx, deviceBinding); err != nil { return fmt.Errorf("failed to update device status: %w", err) } return nil } // createDevice creates an external device in the cluster. Checks if one already exists and if so, // deletes it before creating a new one func createDevice(ctx context.Context, k8sClient client.Client, req DeviceConnectRequest) (*api.ExternalDevice, error) { // check for old external device device := &api.ExternalDevice{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: req.Device.Name, Namespace: config.Namespace, }, device) // no error -> a device was found that we can delete if err == nil { if err := deleteDevice(ctx, k8sClient, device); err != nil { return nil, err } } else if !k8serrors.IsNotFound(err) { // device wasn't found, but another error occurred, so return the error return nil, err } // recreate it device = &api.ExternalDevice{ ObjectMeta: metav1.ObjectMeta{ Name: req.Device.Name, Namespace: config.Namespace, }, Spec: req.Device, } if err := k8sClient.Create(ctx, device); err != nil { return nil, fmt.Errorf("timeout waiting for client secret to be created") } return device, nil } func deleteDevice(ctx context.Context, k8sClient client.Client, device *api.ExternalDevice) error { // delete device err := k8sClient.Delete(ctx, device) if err != nil { return err } err = k8sClient.Delete(ctx, &cmApi.Certificate{ ObjectMeta: metav1.ObjectMeta{ Name: device.GetName(), Namespace: "emissary", }, }) if err != nil && !k8serrors.IsNotFound(err) { return err } err = k8sClient.Delete(ctx, &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: device.GetName() + "-cert", Namespace: "emissary", }, }) if err != nil && !k8serrors.IsNotFound(err) { return err } return nil } func checkRequest(c *gin.Context) (DeviceConnectRequest, error) { var req DeviceConnectRequest if err := c.ShouldBindJSON(&req); err != nil { return req, fmt.Errorf("failed to bind JSON: %w", err) } // Check that the device name has been provided if req.Device.Name == "" { return req, fmt.Errorf("Device Name is required") } // Check that the device SN has been provided if req.Device.SN == "" { return req, fmt.Errorf("Serial Number is required") } return req, nil } // step 2: loop through applicationIDs to create application and construct deviceBinding func getAppSubjectsAndExternalApps(ctx context.Context, k8sClient client.Client, c *gin.Context, req DeviceConnectRequest) ([]api.ApplicationSubject, []*api.ExternalApplication, error) { applicationSubjects := []api.ApplicationSubject{} extApps := []*api.ExternalApplication{} for _, appID := range req.ApplicationIDs { extApp, err := getExternalApplicationByID(ctx, c, k8sClient, appID) if err != nil { return nil, nil, fmt.Errorf("failed to get External Application: %w", err) } extApps = append(extApps, extApp) applicationSubjects = append(applicationSubjects, api.ApplicationSubject{ APIGroup: api.GroupVersion.Group, Kind: "ExternalApplication", Name: extApp.GetName(), ID: extApp.Spec.ID, }, ) } return applicationSubjects, extApps, nil } func createCert(device *api.ExternalDevice, storeID string) *cmApi.Certificate { return &cmApi.Certificate{ ObjectMeta: metav1.ObjectMeta{ Name: device.GetName(), Namespace: "emissary", }, Spec: cmApi.CertificateSpec{ SecretName: device.GetName() + "-cert", IssuerRef: cmMeta.ObjectReference{ Name: "device-registrar-ca-issuer", Kind: "Issuer", Group: "cert-manager.io", }, Usages: []cmApi.KeyUsage{ cmApi.UsageClientAuth, }, Duration: &metav1.Duration{Duration: 8760 * time.Hour}, // 1 year RenewBefore: &metav1.Duration{Duration: 2184 * time.Hour}, // 91 days DNSNames: []string{"edge.store.ncr.corp"}, Subject: &cmApi.X509Subject{ OrganizationalUnits: []string{ "urn:deviceName:" + device.GetName(), "urn:storeID:" + storeID, "urn:SN:" + device.Spec.SN, }, }, }, } } func createClient(device *api.ExternalDevice, extApp *api.ExternalApplication, deviceBinding *api.DeviceBinding) *clientApi.Client { clientName := device.GetName() + "-" + extApp.GetName() + "-client" return &clientApi.Client{ ObjectMeta: metav1.ObjectMeta{ GenerateName: clientName + "-", Namespace: device.GetNamespace(), OwnerReferences: []metav1.OwnerReference{ { APIVersion: api.GroupVersion.String(), Kind: "DeviceBinding", Name: deviceBinding.GetName(), UID: deviceBinding.GetUID(), }, }, }, Spec: extApp.Spec.ClientTemplate.Spec, } } // Waits for a secret to be created with a timeout of 10 seconds func waitForSecret(ctx context.Context, k8sClient client.Client, ns string, secretName string) (corev1.Secret, error) { err := error(nil) secretFound := false timeout := time.After(30 * time.Second) // Example timeout duration, adjust as needed secret := &corev1.Secret{} for !secretFound { select { case <-timeout: return corev1.Secret{}, fmt.Errorf("timeout waiting for client secret to be created %w", err) default: err := k8sClient.Get(ctx, types.NamespacedName{ Name: secretName, Namespace: ns, }, secret) if err == nil { secretFound = true break } if !k8serrors.IsNotFound(err) { return corev1.Secret{}, fmt.Errorf("timeout waiting for client secret to be created %w", err) } time.Sleep(1 * time.Second) // Wait for 1 second before checking again } } return *secret, nil } func getApplicationResponse(ctx context.Context, k8sClient client.Client, devb *api.DeviceBinding) ([]ApplicationResponse, error) { applicationResponse := []ApplicationResponse{} for _, app := range devb.Spec.Applications { clientSecretName := devb.Spec.Device.Name + "-" + app.Name + "-secret" deviceSecret := &corev1.Secret{} err := k8sClient.Get(ctx, types.NamespacedName{Name: clientSecretName, Namespace: devb.Namespace}, deviceSecret) if err != nil { return nil, err } applicationResponse = append(applicationResponse, ApplicationResponse{ ID: app.ID, Name: app.Name, Config: EdgeIDClient{ ClientID: string(deviceSecret.Data["client_id"]), ClientSecret: string(deviceSecret.Data["client_secret"]), }, }) } return applicationResponse, nil } func getDeviceAndAppSubjects(ctx context.Context, k8sClient client.Client, c *gin.Context, req DeviceConnectRequest) (*api.ExternalDevice, []api.ApplicationSubject, []*api.ExternalApplication, error) { device, err := createDevice(ctx, k8sClient, req) if err != nil { return nil, nil, nil, fmt.Errorf("failed to create external device: %w", err) } applicationSubjects, extApps, err := getAppSubjectsAndExternalApps(ctx, k8sClient, c, req) if err != nil { return nil, nil, nil, fmt.Errorf("failed to get External Application: %w", err) } return device, applicationSubjects, extApps, nil } // retrieveDeviceRegistration retrieves the device registration CR based on the device ID func retrieveDeviceRegistration(ctx context.Context, k8sClient client.Client, c *gin.Context) (*api.DeviceBinding, error) { activationCode := c.Param("activationCode") if activationCode == "" { return nil, errActivationCodeNotFound } // Get all device registrations deviceBindingList := &api.DeviceBindingList{} err := k8sClient.List(ctx, deviceBindingList) if err != nil { return nil, fmt.Errorf("failed to list device bindings: %w", err) } for i := range deviceBindingList.Items { device := deviceBindingList.Items[i] // Create a copy to avoid memory aliasing if device.Status.ActivationCode == activationCode { if device.Status.CodeUsed { return nil, errActivationCodeUsed } timestamp := device.Status.Timestamp.Time if time.Since(timestamp) > time.Hour { return nil, errActivationCodeExpired } return &device, err } } return nil, errDeviceBindingNotFound } // Everything below this point is used for documentation purposes and exists for // the purpose of generating the swagger spec only. // The DeviceConnectRequest parameters // // swagger:parameters ConnectDevice type DeviceConnectRequestParams struct { // in: body Body DeviceConnectRequest }