Skip to content
This repository has been archived by the owner on Dec 20, 2022. It is now read-only.

Commit

Permalink
Specify service name in API and use balena service vars
Browse files Browse the repository at this point in the history
Change-type: patch
Signed-off-by: Ken Bannister <[email protected]>
  • Loading branch information
kb2ma committed Mar 7, 2022
1 parent 77c8c74 commit 63b068d
Show file tree
Hide file tree
Showing 2 changed files with 118 additions and 35 deletions.
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This Cloud Function allows you to provision and synchronize a balena device with Google Cloud IoT Core in a secure and automated way via an HTTP endpoint. The Cloud Function may be called by a balena device, as seen in the [cloud-relay](https://github.com/balena-io-examples/cloud-relay) example.

| HTTP Method | Action |
| Method | Action |
|-------------|--------|
| POST | Provisions a balena device with IoT Core. First the function verifies the device UUID with balenaCloud. Then it creates a public/private key pair and adds the device to the registry. Finally the function pushes the private key to a balena device environment variable. |
| DELETE | Removes a balena device from the IoT Core registry and removes the balena device environment variable for the private key. Essentially reverses the actions from provisioning with HTTP POST. |
Expand All @@ -28,6 +28,14 @@ The sections below show how to test the Cloud Function on a local test server an
| GCP_REGISTRY_ID | Google Cloud registry ID you provided to create the registry |
| GCP_SERVICE_ACCOUNT |base64 encoding of the JSON formatted GCP service account credentials provided by Google when you created the service account. Example below, assuming the credentials JSON is contained in a file.<br><br>`cat <credentials.txt> \| base64 -w 0` |

### HTTP API
The HTTP endpoint expects a request containing a JSON body with the attributes below. Use POST to add a device to the cloud registry, DELETE to remove.

| Attribute | Value |
|-----------|-------|
| uuid | UUID of device |
| balena_service | (optional) Name of service container on balena device that uses provisioned key and certificate, for example `cloud-relay`. If defined, creates service level variables; otherwise creates device level variables. Service level variables are more secure. |


### Test locally
The Google Functions Framework is a convenient tool for local testing.
Expand All @@ -41,14 +49,14 @@ export GCP_SERVICE_ACCOUNT=<...>
npx @google-cloud/functions-framework --target=provision
```

Next, use `curl` to send an HTTP request to the local server to provision a device. The provided UUID must be for a legitimate device.
Next, use `curl` to send an HTTP request to the local server to provision a device. See the *HTTP API* section above for body contents.

```
curl -X POST http://localhost:8080 -H "Content-Type:application/json" \
-d '{ "uuid": "<device-uuid>" }'
-d '{ "uuid": "<device-uuid>", "balena_service": "<service-name>" }'
```

After a successful request, you should see the device appear in your IoT Core registry and a `GCP_PRIVATE_KEY` variable appear in balenaCloud for the device.
After a successful POST, you should see the device appear in your IoT Core registry and `GCP_CLIENT_PATH`, `GCP_DATA_TOPIC_ROOT`, `GCP_PRIVATE_KEY`, and `GCP_PROJECT_ID` variables appear in balenaCloud for the device. After a successful DELETE, those variables disappear.

## Deploy
To deploy to Cloud Functions, use the command below. See the [command documentation](https://cloud.google.com/sdk/gcloud/reference/functions/deploy) for the format of `yaml-file`, which contains the variables from the table in the *Development setup* section above.
Expand All @@ -62,3 +70,14 @@ gcloud functions deploy provision --runtime=nodejs14 --trigger-http \
The result is a Cloud Function like below. Notice the `TRIGGER` tab, which provides the URL for the function.

![Alt text](docs/cloud-function.png)

### Test the Cloud Function
To test the function, use a command like below, where the URL is from the `TRIGGER` tab in the console. See the *HTTP API* section above for body contents.

```
curl -X POST https://<region>-<projectID>.cloudfunctions.net/provision \
-H "Content-Type:application/json" \
-d '{ "uuid": "<device-uuid>", "balena_service": "<service-name>" }'
```

After a successful POST, you should see the device appear in your IoT Core registry and `GCP_CLIENT_PATH`, `GCP_DATA_TOPIC_ROOT`, `GCP_PRIVATE_KEY`, and `GCP_PROJECT_ID` variables appear in balenaCloud for the device. After a successful DELETE, those variables disappear.
126 changes: 95 additions & 31 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,41 @@ let registryPath = ''
/**
* Provides create and deletion of GCP IoT Core device and updates balena GCP_PRIVATE_KEY
* environment var. Uses POST request to create and DELETE to delete. Expects request
* body with JSON containing {uuid: <device-uuid>}.
* body with JSON containing {uuid: <device-uuid>, balena_service: <service-name> }.
*/
export async function provision(req, res) {
try {
const badBodyCode = 'provision.request.bad-body'
const creds = { email: process.env.BALENA_EMAIL, password: process.env.BALENA_PASSWORD }
await balena.auth.login(creds)

// Validate and prepare request contents
//console.debug('event:', req)
if (!req || !req.body) {
throw { code: 'provision.request.no-body' }
}
const body = req.body
if (!body.uuid) {
throw { code: badBodyCode }
}

// Validate device with balenaCloud
await balena.models.device.get(req.body.uuid)
const device = await balena.models.device.get(body.uuid)

// lookup balena service if name provided
let service
if (body.balena_service) {
const allServices = await balena.models.service.getAllByApplication(device.belongs_to__application.__id)
for (service of allServices) {
//console.debug("service_name:", service.service_name)
if (service.service_name == body.balena_service) {
break
}
}
if (!service) {
throw { code: badBodyCode }
}
}

// Initialize globals for GCP IoT data
iot = new iotApi.v1.DeviceManagerClient({
Expand All @@ -31,22 +57,26 @@ export async function provision(req, res) {
registryPath = iot.registryPath(process.env.GCP_PROJECT_ID, process.env.GCP_REGION,
process.env.GCP_REGISTRY_ID)

let deviceText = `${body.uuid} for service ${body.balena_service}`
switch (req.method) {
case 'POST':
console.log("Creating device...")
await handlePost(req, res, )
console.log(`Creating device: ${deviceText}...`)
await handlePost(device, service, res)
break
case 'DELETE':
console.log("Deleting device...")
await handleDelete(req, res)
console.log(`Deleting device: ${deviceText}...`)
await handleDelete(device, service, res)
break
default:
throw "method not handled"
}
} catch (error) {
console.log("Error: ", error)
if (error.code === balena.errors.BalenaDeviceNotFound.prototype.code
|| error.code === balena.errors.BalenaInvalidLoginCredentials.prototype.code) {
console.warn("Error: ", error)
// error.code might be an integer
if (error.code && (
error.code === balena.errors.BalenaDeviceNotFound.prototype.code
|| error.code === balena.errors.BalenaInvalidLoginCredentials.prototype.code
|| error.code.toString().startsWith('provision.request'))) {
res.status(400)
} else {
res.status(500)
Expand All @@ -56,49 +86,83 @@ export async function provision(req, res) {
}

/**
* Adds device to GCP IoT registry with new key pair, and adds balena device environment
* var for the private key.
* Adds device to GCP IoT registry with new key pair, and sets balena environment vars.
*
* service: Service on the balena device for which variables are created. If service
* is undefined, creates device level environment variables.
*
* Throws an error on failure to create the device.
*/
async function handlePost(req, res) {
async function handlePost(device, service, res) {
// generate key pair; we only need the private key
const keyPair = await generateKeyPair('ec', {namedCurve: 'prime256v1',
privateKeyEncoding: { type: 'pkcs8', format: 'pem'},
publicKeyEncoding: { type: 'spki', format: 'pem' }
})

const deviceId = `balena-${req.body.uuid}`
const device = {
const deviceId = `balena-${device.uuid}`
const gcpDevice = {
id: deviceId,
credentials: [{ publicKey: { format: 'ES256_PEM', key: keyPair.publicKey } }]
}
await iot.createDevice({ parent: registryPath, device: device })
await iot.createDevice({ parent: registryPath, device: gcpDevice })

await balena.models.device.envVar.set(req.body.uuid, 'GCP_PRIVATE_KEY',
Buffer.from(keyPair.privateKey).toString('base64'))
await balena.models.device.envVar.set(req.body.uuid, 'GCP_CLIENT_PATH',
`${registryPath}/devices/${deviceId}`)
await balena.models.device.envVar.set(req.body.uuid, 'GCP_DATA_TOPIC_ROOT',
`/devices/${deviceId}`)
await balena.models.device.envVar.set(req.body.uuid, 'GCP_PROJECT_ID',
process.env.GCP_PROJECT_ID)
if (service) {
await balena.models.device.serviceVar.set(device.id, service.id, 'GCP_PRIVATE_KEY',
Buffer.from(keyPair.privateKey).toString('base64'))
await balena.models.device.serviceVar.set(device.id, service.id, 'GCP_CLIENT_PATH',
`${registryPath}/devices/${deviceId}`)
await balena.models.device.serviceVar.set(device.id, service.id, 'GCP_DATA_TOPIC_ROOT',
`/devices/${deviceId}`)
await balena.models.device.serviceVar.set(device.id, service.id, 'GCP_PROJECT_ID',
process.env.GCP_PROJECT_ID)
} else {
await balena.models.device.envVar.set(device.uuid, 'GCP_PRIVATE_KEY',
Buffer.from(keyPair.privateKey).toString('base64'))
await balena.models.device.envVar.set(device.uuid, 'GCP_CLIENT_PATH',
`${registryPath}/devices/${deviceId}`)
await balena.models.device.envVar.set(device.uuid, 'GCP_DATA_TOPIC_ROOT',
`/devices/${deviceId}`)
await balena.models.device.envVar.set(device.uuid, 'GCP_PROJECT_ID',
process.env.GCP_PROJECT_ID)
}

console.log(`Created device ${deviceId}`)
res.status(201).send("device created")
}

/**
* Removes device from GCP IoT registry and balena device environment var.
* Removes device from GCP IoT registry, and also removes balena environment vars.
*
* service: Service on the balena device for which variables are removed. If service
* is undefined, removes device level environment variables.
*
* Throws an error on failure to delete the device or key pair.
*/
async function handleDelete(req, res) {
const deviceId = `balena-${req.body.uuid}`
await iot.deleteDevice({ name: `${registryPath}/devices/${deviceId}` })
async function handleDelete(device, service, res) {
const deviceId = `balena-${device.uuid}`
try {
await iot.deleteDevice({ name: `${registryPath}/devices/${deviceId}` })
} catch (error) {
const notFoundCode = 5
if (!error.code || error.code != notFoundCode) {
throw error
} else {
console.warn("Device not found in IoT Core registry")
}
}

await balena.models.device.envVar.remove(req.body.uuid, 'GCP_PRIVATE_KEY')
await balena.models.device.envVar.remove(req.body.uuid, 'GCP_CLIENT_PATH')
await balena.models.device.envVar.remove(req.body.uuid, 'GCP_DATA_TOPIC_ROOT')
await balena.models.device.envVar.remove(req.body.uuid, 'GCP_PROJECT_ID')
if (service) {
await balena.models.device.serviceVar.remove(device.uuid, service.id, 'GCP_PRIVATE_KEY')
await balena.models.device.serviceVar.remove(device.uuid, service.id, 'GCP_CLIENT_PATH')
await balena.models.device.serviceVar.remove(device.uuid, service.id, 'GCP_DATA_TOPIC_ROOT')
await balena.models.device.serviceVar.remove(device.uuid, service.id, 'GCP_PROJECT_ID')
} else {
await balena.models.device.envVar.remove(device.uuid, 'GCP_PRIVATE_KEY')
await balena.models.device.envVar.remove(device.uuid, 'GCP_CLIENT_PATH')
await balena.models.device.envVar.remove(device.uuid, 'GCP_DATA_TOPIC_ROOT')
await balena.models.device.envVar.remove(device.uuid, 'GCP_PROJECT_ID')
}

console.log(`Deleted device ${deviceId}`)
res.status(200).send("device deleted")
Expand Down

0 comments on commit 63b068d

Please sign in to comment.