Skip to Content
Technical Articles
Author's profile photo Laszlo Kajan

Using the TCP Protocol for Node.js Cloud Applications

Goal

Complement the Java example on the SAP help page ‘Consuming the Connectivity Service / Using the TCP Protocol for Cloud Applications‘ with a Node.js example.

Keywords: ‘how to use the SAP BTP CF connectivity service SOCKS5, TCP proxy from a Node.js application?’, ‘how to reach an on-premises TCP service from a BTP CF Node.js application?’

Node.js example

This example provides a SOCKS5 client implementation that uses the connectivity service available in the Business Technology Platform (BTP) Cloud Foundry (CF) environment.

The code defines a connection utility module ‘btp-cf-socks5-proxy-utils’, with type information using JSDoc annotations. Thanks to the JSDoc typing, the module provides TypeScript checks and inline hints when used in the Business Application Studio (BAS):

// @ts-check
'use strict';
/** @type {import('assert/strict')} */
const assert = require('assert').strict;
const SocksClient = require('socks').SocksClient;

let btpCfSocks5ProxyUtils = {

    // See the implementation of assertAuthenticationResponse() at Using the TCP Protocol for Cloud Applications | https://help.sap.com/viewer/cca91383641e40ffbe03bdc78f00f681/Cloud/en-US/cd1583775afa43f0bb9ec69d9dbcc880.html
    SOCKS5_AUTHENTICATION_SUCCESS_BYTE: 0x00,
    SOCKS5_CUSTOM_RESP_SIZE: 2,
    SOCKS5_JWT_AUTHENTICATION_METHOD_VERSION: 0x01,

    /**
     * @typedef {object} ConnectionOptions
     * @prop {string}           [cc_location=""] - cloud connector location, optional
     * @prop {string}           conn_svc_token - JWT token obtained via client_credentials grant for bound connectivity service
     * @prop {string}           remote_host - proxy destination host
     * @prop {number}           remote_port - proxy destination port
     * @prop {string}           onpremise_proxy_host - SOCKS5 proxy host
     * @prop {number | string}  onpremise_socks5_proxy_port - SOCKS5 proxy port
     */

    /**
     * @typedef {import('socks/typings/common/constants').SocksClientEstablishedEvent} SocksClientEstablishedEvent
     */

    /**
     * Creates a new SOCKS connection.
     * @param {ConnectionOptions} opts
     * @returns {Promise<SocksClientEstablishedEvent>}
     */
    createConnection: function (opts) {
        const ccLocation = opts.cc_location || "";

        /**
         * @type {import('socks').SocksClientOptions}
         */
        const options = {
            proxy: {
                host: opts.onpremise_proxy_host,
                port: typeof opts.onpremise_socks5_proxy_port === 'number' ? opts.onpremise_socks5_proxy_port : parseInt(opts.onpremise_socks5_proxy_port, 10),
                type: 5, // Proxy version (4 or 5)
                //
                // SOCKS5 Custom authentication
                custom_auth_method: 0x80,
                custom_auth_request_handler: btpCfSocks5ProxyUtils.getCustomAuthRequestHandler(
                    opts.conn_svc_token, ccLocation),
                custom_auth_response_size: btpCfSocks5ProxyUtils.SOCKS5_CUSTOM_RESP_SIZE,
                custom_auth_response_handler: btpCfSocks5ProxyUtils.customAuthResponseHandler
            },

            command: 'connect', // SOCKS command (createConnection factory function only supports the connect command)

            destination: {
                host: opts.remote_host,
                port: opts.remote_port
            }
        };

        return SocksClient.createConnection(options);
    },

    /**
     * @callback CustomAuthRequestHandler
     * @param {string} connSvcToken - JWT token obtained via client_credentials grant for bound connectivity service
     * @param {string} cloudConnectorLocation - cloud connector location or ""
     * @returns {Promise<Buffer>}
     */
    /** @type {CustomAuthRequestHandler} */
    customAuthRequestHandler: async function (connSvcToken, cloudConnectorLocation) {

        // This will be called when it's time to send the custom auth handshake. You must return a Buffer containing the data to send as your authentication.
        const _1_authMethodVersion = Buffer.from([btpCfSocks5ProxyUtils.SOCKS5_JWT_AUTHENTICATION_METHOD_VERSION]);           // Authentication method version
        const _3_jwtBuf = Buffer.from(connSvcToken, 'binary');      // X bytes: The actual value of the JWT in its encoded form
        const _2_jwtBufLength = Buffer.allocUnsafe(4);              // 4 bytes: Length of the JWT
        _2_jwtBufLength.writeInt32BE(_3_jwtBuf.length);
        const _5_ccNameB64 = Buffer.from(Buffer.from(cloudConnectorLocation).toString('base64'), 'binary');
        // Y - The value of the Cloud Connector location ID in base64-encoded form
        const _4_ccNameLength = Buffer.allocUnsafe(1);              // 1 byte: Length of the Cloud Connector location ID (0 if no Cloud Connector location ID is used)
        _4_ccNameLength.writeUInt8(_5_ccNameB64.length);

        let retBuf = Buffer.alloc(
            _1_authMethodVersion.length +
            _2_jwtBufLength.length +
            _3_jwtBuf.length +
            _4_ccNameLength.length +
            _5_ccNameB64.length
        );

        /**
         * @type {number}
         */
        let offset = 0;
        _1_authMethodVersion.copy(retBuf, offset); offset += _1_authMethodVersion.length;
        _2_jwtBufLength.copy(retBuf, offset); offset += _2_jwtBufLength.length;
        _3_jwtBuf.copy(retBuf, offset); offset += _3_jwtBuf.length;
        _4_ccNameLength.copy(retBuf, offset); offset += _4_ccNameLength.length;
        if (_5_ccNameB64.length > 0) {
            _5_ccNameB64.copy(retBuf, offset); offset += _5_ccNameB64.length;
        }

        assert.equal(offset, retBuf.length);

        return retBuf;
    },

    /**
     * @param {Buffer} data SOCKS proxy authentication response
     * @returns Promise<boolean>
     */
    customAuthResponseHandler: async function (data) {

        assert.equal(data.length, btpCfSocks5ProxyUtils.SOCKS5_CUSTOM_RESP_SIZE);

        const authenticationMethodVersion = data[0];
        const authenticationStatus = data[1];
        // console.log(data);

        if (btpCfSocks5ProxyUtils.SOCKS5_JWT_AUTHENTICATION_METHOD_VERSION !== authenticationMethodVersion) {
            throw new Error(`Unsupported authentication method version - expected ${btpCfSocks5ProxyUtils.SOCKS5_JWT_AUTHENTICATION_METHOD_VERSION}, but received ${authenticationMethodVersion}`);
        }
        if (btpCfSocks5ProxyUtils.SOCKS5_AUTHENTICATION_SUCCESS_BYTE !== authenticationStatus) {
            throw new Error(`Authentication failed (${authenticationStatus})!`);
        }
        return btpCfSocks5ProxyUtils.SOCKS5_AUTHENTICATION_SUCCESS_BYTE === authenticationStatus;
    },

    /**
     * @param {string} connSvcToken - JWT token obtained via client_credentials grant for bound connectivity service
     * @param {string} cloudConnectorLocation - cloud connector location or ""
     * @returns {() => Promise<Buffer>}
     */
    getCustomAuthRequestHandler: function (connSvcToken, cloudConnectorLocation) {
        return btpCfSocks5ProxyUtils.customAuthRequestHandler.bind(null, connSvcToken, cloudConnectorLocation);
    }
};

module.exports = btpCfSocks5ProxyUtils;

This example shows how the ‘btp-cf-socks5-proxy-utils’ module can be used to connect to the SOCKS5 proxy provided by a bound connectivity service:

const btpCfSocks5ProxyUtils = require('btp-cf-socks5-proxy-utils');
const sdkCore = require('@sap-cloud-sdk/core');
const xsenv = require('@sap/xsenv');

// Connectivity service
const connServiceCredentials = xsenv.serviceCredentials({ tag: 'connectivity' });
const connSvcToken = await sdkCore.serviceToken('connectivity', {
                    isolationStrategy: sdkCore.IsolationStrategy.No_Isolation, // there's just one bound connectivity service, no tenants
                    useCache: true
                });

// SOCKS proxy
const info = await btpCfSocks5ProxyUtils.createConnection({
                    cc_location: options.ldapsVirtualLocation,
                    conn_svc_token: connSvcToken,
                    remote_host: options.ldapsVirtualHost,
                    remote_port: options.ldapsVirtualPort,
                    onpremise_proxy_host: connServiceCredentials.onpremise_proxy_host,
                    onpremise_socks5_proxy_port: connServiceCredentials.onpremise_socks5_proxy_port
                });

// Example LDAP client that uses the socket - info.socket - from above
const newLdapClient = new LdapClient({
                        idleTimeout: options.idleTimeoutMillisec || 0,
                        tlsOptions: Object.assign({}, tlsOptions, { socket: info.socket }),
                        url: [ldapUrl]
                    });

Summary

In this blog I presented a to-the-point Node.js example for using the SOCK5 TCP proxy with SAP BTP cloud applications.

(A public, open source module – ‘btp-cf-socks5-proxy-utils’ – of the code presented here is soon to be published.)

Author and motivation

Laszlo Kajan is a full stack SAP developer present on the field since 2015, diversifying into the area of SAP Business Technology Platform (BTP) development.

The motivation behind this blog post is to complement the Java ‘Using the TCP Protocol for Cloud Applications’ example available on help.sap.com with a Node.js example. This is done in the hope that it will same fellow developers some time.

Further reading

Assigned Tags

      3 Comments
      You must be Logged on to comment or reply to a post.
      Author's profile photo Pieter Janssens
      Pieter Janssens

      Hi Laszlo,

      I've been working on a similar module: https://github.com/piejanssens/sap-cf-socks

      Have you tested your client application (ldap in this case) after a longer period of time?

      In my experience the BTP Connectivity service was closing the socket: socket 'end' event after 60s or 10s after last activity. So if you have activity at 55s, it will end at 1m5s.

      Best regards,

      Pieter

      Author's profile photo Manol Valchev
      Manol Valchev

      Hi Laszlo,

      BTP Connectivity service shall not be closing the connection with no reason for this. In fact, there are analytics scenarios based on SOCKS5 proxy feature which serve long-running connections, e.g. ask a database to do complex work and respond back.

      In case the socket is really idle and there's no traffic going on, even lower level hearth beats, then the service would perceive that socket as not used and would close/reset the connection after the threshold is reached.

      Beware that connection re-establishment shall be fast, as the tunnel between the cloud app and premise is kept alive, only the first and last mile of the connection chains are closed.

      Regards,
      Manol

      Author's profile photo Laszlo Kajan
      Laszlo Kajan
      Blog Post Author

      Dear Pieter!

      What a nice module, 'sap-cf-socks', thanks for that! 🙂

      I have an 'idleTimeout' set on my LDAP client, which is 9 seconds at the moment, and then I have logic that re-opens the proxy and then the LDAP connection, when there is a need to search the directory again.

      With this setup, I have not (yet?) observed unexpected losses of connection. Nevertheless I do have one re-attempt programmed using 'backoff' (not shown in this blog post).

      Best regards,
      Laszlo