Amish Kushwaha - October 23, 2026
btp-go Part 3 — Reaching On-Prem: Connectivity and Destination
Part 3 of 5 in the btp-go series. Part 1 · Part 2
Most BTP applications eventually need to talk to something on-premises: an ABAP system, a database, a file server, a legacy RFC endpoint. Those systems live inside a corporate network behind a firewall. BTP runs in a hyperscaler data center. SAP Cloud Connector is the bridge between them — and understanding exactly how that bridge works is prerequisite knowledge for everything in Part 4 and Part 5.
This post explains the Cloud Connector network model, why standard SOCKS5 libraries cannot be used with it, how btp-go’s connectivity module implements the SAP-specific wire protocol, and how the destination module maps human-readable names to the actual connection parameters your app needs.
The Problem: A Firewall in the Way
Corporate IT security does not allow inbound connections from the internet to on-premises systems. That rule is non-negotiable at most enterprises. Cloud Connector works within this constraint by establishing an outbound TLS tunnel from the on-premises network to BTP. The corporate firewall sees only outbound HTTPS traffic — something it already allows.
Once the tunnel is up, BTP applications can route TCP traffic through that tunnel to reach internal hosts. From the application’s perspective, it opens a connection to the Cloud Connector proxy running inside BTP’s network. Cloud Connector forwards the connection through the tunnel to the destination system.
The mapping between virtual hostnames (what the BTP app sees) and real internal hostnames (what Cloud Connector forwards to) is configured in the Cloud Connector administration UI. An app requests my-abap-dev:22 and Cloud Connector maps that to 10.0.1.50:22 inside the corporate network. The app never learns the real IP.
Location IDs add a second dimension: if your BTP subaccount has multiple Cloud Connector installations (for high availability or for separate locations like us-east and eu-west), you specify a Location ID string to route traffic through the correct instance.
┌────────────────────────────────────────────────────────────────────┐
│ BTP (Cloud Foundry / Kyma) │
│ │
│ ┌──────────────┐ ┌──────────────────────────────────────┐ │
│ │ Your App │──────▶│ Connectivity Service Proxy │ │
│ │ │SOCKS5 │ (connectivity.cf.eu10.hana.ondemand │ │
│ └──────────────┘ │ .com:20004) │ │
│ └─────────────────┬────────────────────┘ │
└───────────────────────────────────────────┼────────────────────────┘
│ Outbound TLS tunnel
┌───────────────────────────────────────────┼────────────────────────┐
│ Corporate Network │ │
│ ┌─────▼──────────────┐ │
│ │ Cloud Connector │ │
│ │ (on-prem agent) │ │
│ └─────┬──────────────┘ │
│ │ TCP │
│ ┌─────▼──────────────┐ │
│ │ On-Prem System │ │
│ │ (ABAP, SSH, etc.) │ │
│ └────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘Why Standard SOCKS5 Libraries Don’t Work
SOCKS5 is a well-understood protocol. RFC 1928 defines the handshake:
- Client sends a greeting with a list of supported authentication methods.
- Server selects one method and sends it back.
- Client and server complete the selected auth sub-negotiation.
- Client sends a CONNECT request with the target host and port.
- Server relays traffic.
Standard implementations support methods 0x00 (no auth) and 0x02 (username/password, defined by RFC 1929). You will find these in every SOCKS5 library.
SAP Cloud Connector uses method 0x80 — a proprietary SAP extension. In this method, the client authenticates by providing a JWT from XSUAA, not a username and password. The JWT proves that the calling application is a legitimate BTP tenant with permission to use the Connectivity Service.
No standard SOCKS5 library implements 0x80. They reject any server that proposes it because 0x80 is outside the range they know. This is not a bug in those libraries; 0x80 is in the vendor-specific range of the SOCKS5 spec. It just means you have to implement the handshake yourself.
The connectivity Module: Dialer API
The connectivity module exposes a single type and a constructor:
import "github.com/bluefunda/btp-go/connectivity"
dialer, err := connectivity.NewDialer(connectivity.Config{
ProxyHost: connB.ProxyHost, // from ConnectivityBinding
ProxyPort: connB.ProxyPort, // "20004"
TokenSource: ts, // satisfies connectivity.TokenSource
DialTimeout: 30 * time.Second,
})
if err != nil {
log.Fatal(err)
}
conn, err := dialer.Dial(ctx, "my-abap-dev", "22", "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// conn is a net.Conn — use it like any TCP connection.Dial takes a context, the virtual hostname, the port as a string, and the location ID (empty string for the default location). It returns a net.Conn that speaks directly to the target system. From that point on you can layer any protocol on top: SSH, HTTP, a custom binary protocol.
The TokenSource in Config is a local interface:
// connectivity.TokenSource — identical to xsuaa.TokenSource by design.
type TokenSource interface {
Token(ctx context.Context) (string, error)
}Because Go uses structural typing, the *tokenSource returned by xsuaa.NewClientCredentialsSource satisfies connectivity.TokenSource without any glue code. There is no import of xsuaa from within connectivity — the two modules are fully independent.
Deep Dive: The 0x80 Auth Frame
When TokenSource is set in the config, Dialer.Dial proposes [0x80] as its list of supported auth methods in the SOCKS5 greeting. The greeting frame looks like this:
Byte 0: 0x05 — SOCKS version 5
Byte 1: 0x01 — number of auth methods (1)
Byte 2: 0x80 — method: SAP proprietary JWT authIf Cloud Connector selects 0x80 (it will, when a token source is present), the client must send the auth sub-negotiation frame:
| Offset | Size | Value | Description |
|---|---|---|---|
| 0 | 1 byte | 0x01 | SAP auth version |
| 1 | 4 bytes | uint32 big-endian | Length of JWT bytes |
| 5 | N bytes | JWT string | Bearer token from XSUAA |
| 5+N | 1 byte | uint8 | Length of location ID |
| 5+N+1 | M bytes | location ID string (base64 if needed) | Empty string = default |
After sending this frame, the client reads a two-byte response. 0x01 0x00 means success. Any other value is an auth failure — the JWT was rejected, expired, or the tenant doesn’t have permission.
In Go, writing this frame looks like:
jwtBytes := []byte(jwt)
locationBytes := []byte(locationID)
buf := make([]byte, 1+4+len(jwtBytes)+1+len(locationBytes))
buf[0] = 0x01
binary.BigEndian.PutUint32(buf[1:5], uint32(len(jwtBytes)))
copy(buf[5:], jwtBytes)
buf[5+len(jwtBytes)] = uint8(len(locationBytes))
copy(buf[6+len(jwtBytes):], locationBytes)
if _, err := conn.Write(buf); err != nil {
return fmt.Errorf("auth write: %w", err)
}After auth succeeds, the CONNECT request uses the standard SOCKS5 format with ATYP=0x03 (domain name) rather than 0x01 (IPv4). Cloud Connector resolves the virtual hostname against its internal mapping table.
When no TokenSource is set, Dialer proposes [0x00] (no auth) in the greeting. Cloud Connector accepts this for scenarios where the XSUAA credentials are already embedded in the destination configuration rather than passed per-connection.
StageError: Debugging Tunnel Failures
TCP tunnel failures are notoriously hard to debug because the error can occur at any of several distinct protocol stages. “connection refused” at the CONNECT stage means something different from “connection refused” at the initial TCP dial stage — one means the proxy is unreachable, the other means the virtual host mapping doesn’t exist or is not permitted.
The connectivity module wraps errors with stage labels using StageError:
type StageError struct {
Stage string // "dial", "greeting", "auth", "connect"
Err error
}
func (e *StageError) Error() string {
return fmt.Sprintf("connectivity %s: %v", e.Stage, e.Err)
}
func (e *StageError) Unwrap() error {
return e.Err
}A failed auth gives you connectivity auth: server rejected credentials (0x01 0x01). A refused CONNECT gives you connectivity connect: SOCKS5 error 0x05 (connection refused). Both implement errors.As so you can check the stage programmatically:
conn, err := dialer.Dial(ctx, host, port, locationID)
if err != nil {
var stageErr *connectivity.StageError
if errors.As(err, &stageErr) {
switch stageErr.Stage {
case "auth":
log.Printf("XSUAA token rejected — check service binding permissions")
case "connect":
log.Printf("virtual host not mapped in Cloud Connector — check SCC config")
case "dial":
log.Printf("Connectivity proxy unreachable — check binding ProxyHost/ProxyPort")
}
}
return err
}This is one of those small API decisions that saves hours of debugging in production. A generic error pointing at a TCP error would leave you guessing which layer of the five-layer protocol stack went wrong.
The destination Module: What It Does
Cloud Connector handles the transport question: how to open a TCP connection to a system inside the corporate network. The Destination Service handles the configuration question: what host, port, authentication, and protocol should I use for a logical destination named “my-sap-system”?
The SAP Destination Service is a BTP service that stores named destinations. Each destination captures everything needed to connect to a remote system: the virtual hostname (for on-prem systems, this is the Cloud Connector virtual host), the port, the authentication type, credentials, and metadata. Applications look up destinations by name rather than hardcoding connection strings — this lets operators change connection targets and credentials without redeploying applications.
The destination module wraps the Destination Service REST API:
import "github.com/bluefunda/btp-go/destination"
// destB is from binding.Destination(prov, "")
client := destination.NewClient(destB.ServiceURL, ts, http.DefaultClient)
dest, err := client.Find(ctx, "my-sftp-server")
if err != nil {
log.Fatalf("destination lookup failed: %v", err)
}Find issues a GET request to {serviceURL}/destination-configuration/v1/destinations/{name} with a Bearer token from the token source. The response is a JSON payload that the module decodes into a Destination struct.
The Destination Struct Walkthrough
type Destination struct {
Name string
Type string // "HTTP", "TCP", "MAIL", "RFC", "LDAP"
ProxyType string // "Internet", "OnPremise", "PrivateLink"
Authentication string // "BasicAuthentication", "OAuth2ClientCredentials", etc.
URL string // full URL (HTTP destinations)
Host string // hostname (TCP destinations)
Port string // port as string
User string
Password string
Path string
CloudConnectorLocationID string
Properties map[string]string // any field not explicitly modeled
AuthTokens []AuthToken // pre-fetched tokens from Destination Service
}A few things worth noting:
Type and ProxyType together tell you the full picture. Type=TCP, ProxyType=OnPremise means “open a raw TCP connection through Cloud Connector.” Type=HTTP, ProxyType=OnPremise means “route HTTP requests through Cloud Connector.” Type=HTTP, ProxyType=Internet means “call an internet endpoint directly.”
AuthTokens are pre-fetched bearer tokens that the Destination Service has obtained on your behalf. For destinations configured with OAuth2ClientCredentials authentication, the Destination Service fetches a token from the OAuth2 server and attaches it to the response. This is particularly useful for destinations that authenticate to HTTP endpoints — you get the token without needing to implement the OAuth2 flow yourself. The AuthToken struct includes the token type, value, the HTTP header it should be attached to, its expiry, and any error that occurred during fetching.
Properties is the catch-all. The Destination Service allows administrators to set arbitrary key-value pairs on a destination (the “Additional Properties” section in the BTP cockpit). Any field in the API response that isn’t explicitly modeled in the Destination struct ends up in Properties. The sshclient module reads Properties["sshKey"] for SSH private key material, for example.
BestAuthToken: Why It Exists
When a destination has multiple AuthTokens, your code needs to pick the right one. The Destination Service may return several tokens with different types. BestAuthToken() implements a priority order: prefer bearer tokens over other types, skip any token with a non-empty Error field, and return the first match.
authToken := dest.BestAuthToken()
if authToken == nil {
log.Fatal("destination has no usable auth token")
}
req.Header.Set(authToken.HTTPHeader.Key, authToken.HTTPHeader.Value)You could implement this yourself, but the priority logic is easy to get wrong — especially the error-field check, which is a Destination Service quirk that most documentation glosses over.
Composing connectivity and destination
The reason connectivity and destination are separate modules with no import relationship between them is that they represent orthogonal concerns. A destination lookup gives you the what (host, port, auth). A connectivity.Dialer gives you the how (tunnel through Cloud Connector). You compose them in your application layer:
// Look up where to connect.
dest, err := destClient.Find(ctx, "my-sftp-server")
if err != nil {
return err
}
// Open the tunnel.
conn, err := dialer.Dial(ctx, dest.Host, dest.Port, dest.CloudConnectorLocationID)
if err != nil {
return err
}
// Use the connection — hand it to SSH, OData HTTP client, whatever you need.Neither module needs to know about the other. The destination module does not know how connections are opened. The connectivity module does not know what a destination is. Your application code is the glue, and it is two function calls.
This also means you can test each module in isolation. Testing destination.Find is an HTTP mock. Testing connectivity.Dial is a raw TCP mock. There is no entangled state to simulate.
Full Wiring Example: Connectivity + Destination
Here is a complete setup that could serve as the foundation for any on-premises TCP use case:
package main
import (
"context"
"log"
"net/http"
"time"
"github.com/bluefunda/btp-go/binding"
"github.com/bluefunda/btp-go/binding/auto"
"github.com/bluefunda/btp-go/connectivity"
"github.com/bluefunda/btp-go/destination"
"github.com/bluefunda/btp-go/xsuaa"
)
func setup(ctx context.Context) (*connectivity.Dialer, *destination.Client, error) {
// Detect runtime and load bindings.
prov, err := auto.NewProvider()
if err != nil {
return nil, nil, err
}
// Extract XSUAA credentials and build a token source.
xsuaaB, err := binding.XSUAA(prov, "")
if err != nil {
return nil, nil, err
}
ts := xsuaa.NewClientCredentialsSource(xsuaa.Config{
ClientID: xsuaaB.ClientID,
ClientSecret: xsuaaB.ClientSecret,
TokenURL: xsuaaB.TokenURL,
})
// Build the connectivity dialer using the Connectivity service binding.
connB, err := binding.Connectivity(prov, "")
if err != nil {
return nil, nil, err
}
dialer, err := connectivity.NewDialer(connectivity.Config{
ProxyHost: connB.ProxyHost,
ProxyPort: connB.ProxyPort,
TokenSource: ts,
DialTimeout: 30 * time.Second,
})
if err != nil {
return nil, nil, err
}
// Build the destination client.
destB, err := binding.Destination(prov, "")
if err != nil {
return nil, nil, err
}
destClient := destination.NewClient(destB.ServiceURL, ts, http.DefaultClient)
return dialer, destClient, nil
}
func connect(ctx context.Context, dialer *connectivity.Dialer, destClient *destination.Client, destName string) error {
// Resolve the destination.
dest, err := destClient.Find(ctx, destName)
if err != nil {
return err
}
log.Printf("found destination: host=%s port=%s type=%s proxyType=%s",
dest.Host, dest.Port, dest.Type, dest.ProxyType)
// Open the tunnel (only for OnPremise destinations).
conn, err := dialer.Dial(ctx, dest.Host, dest.Port, dest.CloudConnectorLocationID)
if err != nil {
return err
}
defer conn.Close()
log.Printf("tunnel open to %s:%s", dest.Host, dest.Port)
// Hand conn to SSH, OData client, etc.
return nil
}The setup function runs once at startup. The connect function runs for each request or can be called once to establish a long-lived connection. The destination lookup is cheap (it is one HTTP call) and the result can be cached if the destination configuration is stable.
What’s Next
Part 3 establishes the tunnel and resolves the destination. Part 4 puts both to work in a concrete scenario: counting files in the SAP AL11 directory over SFTP.
AL11 is the SAP transaction that shows the ABAP application server’s filesystem. Accessing it without RFC used to require either custom ABAP code or a GUI session. With Cloud Connector and SSH configured on the ABAP server, btp-go can read it directly from BTP using standard Go SFTP libraries — no custom ABAP, no RFC call, no GUI.
Next up — Part 4: Worked Example: Counting Files in AL11 over SFTP.
Ready to transform your SAP experience?
Join thousands of developers using BlueFunda AI tools.