The Internet of Things on AWS – Official Blog

How to remote access devices from a web browser using secure tunneling

Using firewalls is a common way to protect and secure access to IoT devices. Yet, it’s challenging to access and manage devices deployed at remote sites, behind firewalls that block all inbound traffic. Troubleshooting devices can involve sending technicians onsite to connect to those devices. This increases the complexity and the cost of device management.

Secure Tunneling is a feature of AWS IoT Device Management that helps customers accessing remote devices over a secure connection that is managed by AWS IoT. Secure Tunneling does not require updates to your existing inbound firewall rules, so you can keep the same security level provided by firewall rules at a remote site.

In this post, you learn how to use secure tunneling to start a Secure Shell (SSH) session to remote devices from web application. This connection can be used for configuration, troubleshooting, and to complete other operational tasks.

You can download the source code of this implementation from GitHub.

Solution overview

I will walk you through the steps for building a web based local proxy to gain access to remote devices using secure tunneling.
The local proxy is a software proxy that runs on the source, and destination devices. The local proxy relays a data stream over a WebSocket secure connection between the Secure tunneling service and the device application.

The local proxy can work in source, or destination mode. The source is usually the laptop or the desktop computer you use to initiate a session with the destination device. The destination device is the remote device you want to access.

For an overview of the process, review the following diagram.

AWS IoT Secure Tunneling

When you create a tunnel, a pair of tokens (one for the source and one for the destination) is created. The source and destination devices use these tokens to connect to the secure tunneling service.

The local proxy establishes a secure WebSocket connection with the tunneling feature using the source or the destination token, depending on the mode used. The token is specified in the request either via cookie, named awsiot-tunnel-token, or an HTTP request header, named access-token.

The implementation of WebSockets inside web browsers doesn’t support custom headers. So you must set an awsiot-tunnel-token cookie using the instruction in the secure tunneling protocol guide.

For security reasons, a website can only set a cookie for its own domain, or any higher-level DNS domain it belongs to. For example, if the domain name of a web application is mylocalproxy.com, it could not set a cookie for the secure tunneling endpoint named data.tunneling.iot.{aws-region}.amazonaws.com.

You will use Amazon API Gateway with AWS Lambda proxy integration to set the cookie for the .amazonaws.com domain.
The cookie is shared across the setting domain and all sibling and child domains including data.tunneling.iot.{aws-region}.amazonaws.com.

Web browsers might not send the cookie to the domain us-east-1.amazonaws.com as it is in the public suffix list. This list is used in browsers to limit the scope of a cookie. A manual workaround for us-east-1 Region is to set the cookie in the console of the web browser.

Solution architecture

The following diagram gives an overview of the major steps involved in starting an SSH session from a web application using Secure Tunneling:

AWS IoT Secure Tunneling with a web browser

  1. Set a cookie named awsiot-tunnel-token with the value of the source token.
  2. Open a secure WebSocket connection between your web application and the tunneling feature.
  3. Transfer the data using Protocol Buffers library.

In this blog, I describe these three steps in detail starting with how to open a tunnel. Once the tunnel is open, I walk you through how to open a secure WebSocket connection, first from a local machine, setting the source access token via HTTP header.

Then, I explain how to use Protocol Buffers library to transfer data between a source and a destination.

Finally, I describe the solution to set a cookie for the .amazonaws.com domain so the web application can open a secure WebSocket connection passing this cookie.

Prerequisites

This post assumes you have completed the following:

Walkthrough

Step 1: Connecting to Secure Tunneling

The first step is to open a tunnel and download the access tokens for the source and destination as described in open a tunnel and start SSH session to remote device.

  • a) Create a folder in your local machine. Navigate to this folder, and create a file named connect.js.
  • b) Copy the following Node.js script in your newly created connect.js file. Replace the value of token with the access token for the source you have downloaded. Replace the value of aws_region with the AWS Region in which the tunnel is open. The access token for the source is used to open a WebSocket connection between your local machine and the tunneling service.
// connect.js
const WebSocket = require('ws')
const token = 'REPLACE WITH THE SOURCE TOKEN'
const aws_region = 'REPLACE WITH THE AWS REGION IN WHICH THE TUNNEL IS OPEN'
const mode = 'source'

let url = `wss://data.tunneling.iot.${aws_region}.amazonaws.com:443/tunnel?local-proxy-mode=${mode}` 

const connection = new WebSocket(url, `aws.iot.securetunneling-2.0`, {
    headers: { 
            'access-token': token
    }
})

connection.onopen = async () => {
    console.log('Source is connected to the tunneling service')
}
  • c) Install the Node.js library ws, with the following command:

npm i --save ws

  • d) Run the script:

node connect.js

You see Source is connected to the tunneling service in your terminal.

  • e) In the AWS IoT console, select your tunnel and check that the source is connected.

  • f) To connect the destination to the tunneling service, repeat this step. Replace the value of the variable mode with destination. Replace the value of token with the access token for the destination.

Step 2: Transmitting data through the tunnel

Now that you know how to connect the source and the destination to the tunneling feature, you can transmit data. Secure Tunneling uses protocol buffers to transfer data between the source and the destination.

Protocol Buffers is a mechanism for serializing structured data. Protocol Buffers enables you to specify a schema for your data in a .proto file.

  • a) In the folder created in Step 1, create a file named schema.proto Copy the following content into the file:
// schema.proto 

syntax = "proto3";

package com.amazonaws.iot.securedtunneling;

option java_outer_classname = "Protobuf";
option optimize_for = LITE_RUNTIME;

message Message {
    Type    type         = 1;
    int32   streamId     = 2;
    bool    ignorable    = 3;
    bytes   payload      = 4;
    string  serviceId    = 5;
    repeated string availableServiceIds = 6;
    
    enum Type {
        UNKNOWN = 0;
        DATA = 1;
        STREAM_START = 2;
        STREAM_RESET = 3;
        SESSION_RESET = 4;
        SERVICE_IDS = 5;
    }
}

The previous schema defines a message format for the data with six fields: type, streamId, ignorable, payload, serviceId and availableServiceIds.

The payload field contains a binary blob of the data to transfer. For more information, review the reference implementation guide V2WebSocketProtocolGuide.

  • b) In the same folder, install the library protobufjs that you will use to load the schema and encode/decode the messages:

npm i --save protobufjs

  • c) Create two files. Name one file source.js. Name the other file destination.js. You connect the destination to the tunneling feature and decode the incoming message in the file destination.js. You connect the source to the tunneling feature and send a message to the destination with the file source.js.
  • d) Copy the following content in the destination.js file. Replace the values for token and aws_region:
// destination.js 

const WebSocket = require('ws')
const {load} = require('protobufjs')

const token = 'REPLACE WITH THE DESTINATION TOKEN'
const aws_region = 'REPLACE WITH THE AWS REGION IN WHICH THE TUNNEL IS OPEN'

const mode = 'destination'
const protopath = './schema.proto'

let url = `wss://data.tunneling.iot.${aws_region}.amazonaws.com:443/tunnel?local-proxy-mode=${mode}`
let Message

const connection = new WebSocket(url, `aws.iot.securetunneling-2.0`, {
    headers: { 
            'access-token': token
    }
})

connection.onopen = async () => {
    console.log('Destination is connected to the tunneling service')
    Message = await load(protopath)
    Message = Message.root.lookupType('Message')
}

connection.onmessage = async ({data}) => {
    try {
        let decoded_message = Message?.decode(data)
        if(decoded_message?.payload){
            console.log(decoded_message.payload.toString('utf-8'))
        }
    } catch (e) {
        console.log(e)
    }
} 
  • e) Open the source.js file and copy the following code. Replace the values for token and aws_region.
const WebSocket = require('ws')
const {load} = require('protobufjs')

const token = 'REPLACE WITH THE SOURCE TOKEN'
const aws_region = 'REPLACE WITH THE AWS REGION IN WHICH THE TUNNEL IS OPEN'

const mode = 'source'
const protopath = './schema.proto'

let url = `wss://data.tunneling.iot.${aws_region}.amazonaws.com:443/tunnel?local-proxy-mode=${mode}`
let Message

const hello = 'Hello from the source'

const connection = new WebSocket(url, `aws.iot.securetunneling-2.0`, {
    headers: { 
            'access-token': token
    }
})

connection.onopen = async () => {
    console.log('Source is connected to the tunneling service')
    Message = await load(protopath)
    Message = Message.root.lookupType('Message')

    // start the stream 
    let tunnel_message = {
        type: 2, // Stream Start
        streamId: Math.floor(Math.random() * 1000), 
        ignorable: false,
        payload: null // We don't send data yet as we only start the stream
    }
    sendData(tunnel_message)

    // send the data 
    tunnel_message.type = 1 // DATA
    tunnel_message.payload = Buffer.from(hello, 'utf-8')
    sendData(tunnel_message)
}

connection.onmessage = async ({data}) => {
    try {
        let decoded_message = Message?.decode(data)
        if(decoded_message?.payload){
            console.log(decoded_message.payload.toString('utf-8'))
        }
    } catch (e) {
        console.log(e)
    }
}

const sendData = (data) => {
    try {
            let protoMessage = Message.verify(data)
            let encodedMessage = Message.encode(data).finish()
            let arrayWrapper  = new Uint8Array( 2 + encodedMessage.byteLength );
            arrayWrapper.set( new Uint8Array( [ Math.floor(encodedMessage.byteLength / 256), encodedMessage.byteLength % 256 ] ))
            arrayWrapper.set(encodedMessage, 2);
            connection.send(arrayWrapper)
        
    } catch (e) {
        console.log(e)
    }
}
  • f) Open a terminal for the destination. In destination terminal, run the destination.js script:

node destination.js

  • g) Open an additional terminal for the source. In the source terminal, run the source.js script:

node source.js

You see the message Hello from the source sent by the source (see the variable hello) received by the destination.

AWS IoT Secure Tunneling message

In this step, you transferred simple text between the source and the destination. If there was an SSH session, the payload of the protobuf message would contain an SSH stream.

Step 3: Create the REST API that sets the cookie

Now that you know how to connect and transfer data, the last step is to connect to the tunneling service from a web browser. The implementation of WebSockets inside web browsers doesn’t support custom headers, so you must set a cookie, as described in the Secure Tunneling protocol guide.

To set a cookie to pass the source token for authentication when creating a new WebSocket connection, you create a REST API with Amazon API Gateway with AWS Lambda proxy integration.

The web application sends an HTTP POST request providing the token to the API Gateway endpoint. The Lambda function creates the cookie with the provided token. It responds to the POST API request with the Set-Cookie HTTP response header to send the cookie to the web application.

AWS IoT Secure Tunneling

The endpoint of the API you create, and the endpoint to connect to the tunneling service are both subdomains of .amazonaws.com.

Step 3.1: Create the Lambda function to set the cookie

You create a Node.js Lambda function using the Lambda console.

  • a) Open the Functions page on the Lambda console.
  • b) Choose Create function.
  • c) Under Basic information, do the following:
    • For Function name, enter set_cookie_lambda.
    • For Runtime, confirm that Node.js 14.x is selected.
  • d) Choose Create function.
  • e) Under Function code, in the inline code editor, copy/paste the following code:
// set_cookie_lambda Lambda function

exports.handler = async (event) => {

    const body = JSON.parse(event.body)
    const token = body.token
    const origin = event.headers['origin']

    let d = new Date()
    d.setTime(d.getTime() + (2*60*60*1000))

    let cookie = `awsiot-tunnel-token=${token}; path=/tunnel; expires=${d}; domain=.amazonaws.com; SameSite=None; Secure; HttpOnly`

    const response = {
        headers: {
            'Set-Cookie': cookie,
            'Access-Control-Allow-Origin': origin,
            'Access-Control-Allow-Credentials': true
        },
        statusCode: 200,
        body: JSON.stringify({message: 'Success'})
    };
    return response
}
  • f) Choose Deploy.

Step 3.2: Create the Lambda function to enable CORS

For the API to be able to set the cookie, you must enable cross-origin resource sharing (CORS). CORS is a browser security feature that restricts cross-origin HTTP requests that are initiated from scripts running in the browser.

For a CORS request with credentials, you can’t use the wildcard “*” in the value of Access-Control-Allow-Origin header. Instead, you must specify the origin.

To support CORS, therefore, a REST API resource must implement an OPTIONS method that can respond to the OPTIONS preflight request with at least the following response headers: Access-Control-Request-Method, Access-Control-Request-Headers, and the Origin header.

To do that you will create another Lambda function that will get the origin of the web application from the OPTIONS method of the API and enable CORS for this specific origin.

Repeat the steps described in the Step 3.1 to create a Node.js Lambda function named enable_cors_lambda.

You create a Node.js Lambda function using the Lambda console.

  • a) Open the Functions page on the Lambda console.
  • b) Choose Create function.
  • c) Under Basic information, do the following:
    • For Function name, enter set_cookie_lambda.
    • For Runtime, confirm that Node.js 14.x is selected.
  • d) Choose Create function.
  • e) Under Function code, in the inline code editor, copy/paste the following code:
// enable_cors_lambda Lambda function
exports.handler = async (event) = {

    const origin = event.headers['origin']
    const response = {
        headers: {
            'Access-Control-Allow-Origin': origin,
            'Access-Control-Allow-Credentials': true,
            'Access-Control-Allow-Methods': 'OPTIONS,GET, POST',
            'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
            
        },
        statusCode: 200,
        body: JSON.stringify({message: 'Success'})
    };
    return response;
}
  • f) Choose Deploy.

Step 3.3: Creating the REST API to set the cookie

Now, you can create the REST API with POST and OPTIONS methods that will invoke the Lambda functions set_cookie_lambda and enable_cors_lambda respectively.

  • a) In the API Gateway console, create a REST API named SetCookieApi.
  • b) Create a method POST.
    • Leave the Integration type set to Lambda Function.
    • Choose Use Lambda Proxy integration.
    • From the Lambda Region dropdown menu, choose the region where you created the set_cookie_lambda Lambda function.
    • In the Lambda Function field, type any character and choose set_cookie_lambda from the dropdown menu.
    • Choose Save.
    • Choose OK when prompted with Add Permission to Lambda Function.

  • c) Create a method OPTIONS.
    • Leave the Integration type set to Lambda Function.
    • Choose Use Lambda Proxy integration.
    • From the Lambda Region dropdown menu, choose the region where you created the enable_cors_lambda Lambda function.
    • In the Lambda Function field, type any character and choose enable_cors_lambda from the dropdown menu.
    • Choose Save.
    • Choose OK when prompted with Add Permission to Lambda Function.

Step 3.4: Deploy the API

  • Choose Deploy API from the Actions dropdown menu.
  • For Deployment stage, choose [new stage].
  • For Stage name, enter api.
  • Choose Deploy.
  • Note the API’s Invoke URL.

You can send a POST request providing the token in the body using the Invoke URL.

The API sends the cookie in the response. When you open the WebSocket connection with the tunneling service, the cookie will be used to authenticate with the tunneling service.

Step 4: Connect to the tunneling feature from a web application

You can now use the SetCookieApi API in your web application to connect to the tunneling feature.

The following code snippet of an Angular web application shows how to use the REST API to set the cookie:

  • You send an HTTP POST request to the SetCookieApi API with the token in the body.
  • The API sets the cookie in the response.
  • Finally, you open a secure WebSocket connection with the tunneling feature.
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http'

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit{
  
  token = 'REPLACE WITH THE SOURCE TOKEN'
  aws_region = 'REPLACE WITH THE AWS REGION IN WHICH THE TUNNEL IS OPEN'
  url_api_set_cookie = 'REPLACE WITH THE SetCookieApi URL'
  tunneling_url = `wss://data.tunneling.iot.${this.aws_region}.amazonaws.com:443/tunnel?local-proxy-mode=source`
  constructor(private http: HttpClient){}

  async ngOnInit() {
    // SET THE COOKIE 
    await this.http.post(this.url_api_set_cookie, {token: this.token}, {withCredentials: true, }).toPromise()
    
    // Connect to the tunneling service
    let socket = new WebSocket(this.tunneling_url, 'aws.iot.securetunneling-2.0')

  }

}

Once the WebSocket connection is established, you can transfer data like SSH stream directly from your web application.

You can find an implementation of a web based local proxy in the aws-iot-securetunneling-web-ssh GitHub repository.

You can also test using an online demonstration. The demo user name and the password are both iotcore.

Cleaning up

To avoid incurring future charges, delete the resources created during this walkthrough.

Conclusion

Secure Tunneling provides a secure, remote access solution that directly integrates with AWS IoT to allow you to access your IoT devices remotely from anywhere.

In this blog, you learned how to use this AWS IoT Device Management feature to gain access to remote devices from a web application. This can simplify the configuration, and reduce the time for troubleshooting devices that are behind firewalls.

You can use this implementation to build or enhance a device management web application to view, interact, and connect to your fleet of devices. You can customize the implementation provided in the aws-iot-securetunneling-web-ssh GitHub repository to build a solution that fits your needs.

You can also test using an online demonstration. The demo user name and the password are both iotcore.