Skip to content

Ikanos — Guide - Reverse Tunnel for Private APIs

Table of Contents


Overview

Many real-world integrations need an Ikanos capability to call an HTTP API that lives inside a corporate network — a CRM, an ERP, an internal database — while the engine itself runs in a public environment (cloud, SaaS, customer's edge). Three options are tempting but each has a serious drawback:

Option Why it's a bad idea
Open an inbound firewall rule to the private API Security teams refuse, and rightly — every rule is a permanent attack surface
Move Ikanos into the private network Defeats the point of running capabilities centrally / publicly / as SaaS
Build a bespoke HTTP proxy Out of scope for a declarative engine, and easy to get wrong

The correct answer is a reverse tunnel: a process on the private side dials outbound to a relay (or directly to Ikanos), then accepts traffic back through the established connection. No inbound firewall rules. No public exposure of the private API. No public DNS.

Ikanos 1.0.0-alpha3 ships two supported ways to do this:

Path Shape When
A — Embedded OpenZiti (new in Alpha 3) tunnel: block in the capability YAML; the engine dials the overlay directly via the ikanos-tunnel-ziti module First choice — declarative, no sidecar, identity per capability
B — Sidecar tunnel A separate process (frpc, cloudflared, ziti-edge-tunnel) next to Ikanos; the capability sees a loopback endpoint Fallback — when you need FRP / Cloudflare, or you cannot extend the JVM classpath

Both paths preserve the no-inbound-firewall-rule property. Path A makes the tunnel a first-class part of the capability contract; Path B keeps it as deployment infrastructure.


When to use this guide

Use a reverse tunnel when all of the following are true:

  • The upstream API lives behind a firewall that denies inbound traffic.
  • You cannot (or do not want to) move Ikanos into that network.
  • You can run one matching process on the private side (a Ziti edge router for Path A, or the matching tunnel client for Path B).

If even one of those is not true, prefer the simpler path: a public API plus an authentication layer declared with the standard authentication: block.


New in Ikanos 1.0.0-alpha3. The engine dials the consumed API directly through an OpenZiti overlay using the Java SDK — no sidecar, no 127.0.0.1 indirection, no port mapping. The capability YAML declares the tunnel; the engine handles the rest.

At a glance — drop the ikanos-tunnel-ziti jar on the classpath, add a tunnel: block to consumes.http, mount a Ziti identity through binds, and that's it. baseUri keeps its real hostname.

How the embedded tunnel works

   PUBLIC NETWORK                                  │           PRIVATE NETWORK
   ┌──────────────────────────────────┐            │     ┌─────────────────────────┐
   │ Ikanos engine                    │            │     │ Internal API            │
   │   consumes.http:                 │            │     │ (CRM, ERP, DB, …)       │
   │     baseUri: https://crm.internal│            │     └────────────▲────────────┘
   │     tunnel: { type: ziti, … }    │            │                  │ HTTP(S)
   │                                  │            │                  │
   │   ┌──────────────────────────┐   │            │     ┌────────────┴────────────┐
   │   │ OpenZiti Java SDK        │ ──┼─ overlay ──┼─────│ Ziti edge router        │
   │   │ (ikanos-tunnel-ziti)     │   │ (mTLS,     │     │ + private "host" ident. │
   │   └──────────────────────────┘   │  outbound) │     └─────────────────────────┘
   └──────────────────────────────────┘            │
                                Ziti controller    │
                              (self-hosted, OSS)   │

Key invariants:

  1. The engine dials the overlay — no extra container, no loopback hop. The Tunnel SPI (io.ikanos.engine.consumes.tunnel.Tunnel) replaces the default HttpClient socket factory when a tunnel: block is declared.
  2. The private side dials out — the Ziti edge router on the private network establishes an outbound mTLS connection to the controller. The firewall only needs to permit outbound to the controller / edge router endpoints.
  3. baseUri is the real hostname. https://crm.internal resolves through the Ziti intercept.v1 config, not DNS. No 127.0.0.1 placeholder, no synthetic hosts file.
  4. Authentication still runs end-to-end on top of the tunnel. Bearer tokens, OAuth2, API keys declared via authentication: are unaffected — the tunnel is transport.

Capability YAML

ikanos: "1.0.0-alpha3"

info:
  display: "CRM reverse-tunnel example"
  description: "Reach an internal CRM API over an OpenZiti reverse tunnel."

binds:
  - namespace: "secrets"
    description: "Credentials for the private CRM and the Ziti identity bundle."
    location: "file:///./shared/secrets.yaml"
    keys:
      ZITI_IDENTITY: "ziti-identity-path"
      CRM_TOKEN: "crm-bearer-token"

capability:
  consumes:
    - type: "http"
      namespace: "crm"
      description: "Customer Relationship Management API on the private network."
      baseUri: "https://crm.internal"
      authentication:
        type: "bearer"
        token: "{{secrets.CRM_TOKEN}}"
      tunnel:
        type: "ziti"
        service: "crm-api"
        identity: "{{secrets.ZITI_IDENTITY}}"
        fallback: "fail"
      resources:
        customers:
          description: "Customer records."
          path: "/customers"
          operations:
            list-customers:
              method: "GET"
              description: "List all customers."

Field reference for tunnel::

Field Required Value
type yes ziti (only value supported in Alpha 3 — additional types may be added later via the type discriminator)
service yes Name of the Ziti service the controller authorises this identity to dial
identity yes Path to a Ziti identity file (JSON / PFX). Use a Mustache reference into binds — never inline the identity bundle. The Polychro rule ikanos-tunnel-identity-must-bind enforces this
fallback no fail (default) — operations return a 503-equivalent when the tunnel is down. direct — engine falls back to dialing baseUri over the public internet (development only); the ikanos-tunnel-fallback-direct-warns Polychro rule flags it on non-loopback URIs

Adding the tunnel module to your deployment

The embedded path is an opt-in jar so the OpenZiti SDK is not pulled into deployments that do not need it.

Maven dependency:

<dependency>
    <groupId>io.ikanos</groupId>
    <artifactId>ikanos-tunnel-ziti</artifactId>
    <version>1.0.0-alpha3</version>
</dependency>

Container image:

FROM ghcr.io/naftiko/ikanos:1.0.0-alpha3
# Drop the tunnel module on the classpath — discovered via ServiceLoader.
ADD https://repo.maven.apache.org/maven2/io/ikanos/ikanos-tunnel-ziti/1.0.0-alpha3/ikanos-tunnel-ziti-1.0.0-alpha3.jar \
    /app/lib/

The module registers an implementation of io.ikanos.engine.consumes.tunnel.Tunnel through META-INF/services. At startup the engine discovers it by ServiceLoader, reads the Mustache-resolved tunnel.identity from the matching consumes.http, and installs the tunnel socket factory on the HttpClient used for that endpoint.

Validation works without the jar. Parsing, schema validation, and the Polychro linter rules all run from the spec alone. You only need the ikanos-tunnel-ziti jar at runtime when the engine actually dials.

Identity provisioning and rotation

Each capability gets its own Ziti identity. The standard flow:

  1. Enroll the identity on the Ziti controller — produces a one-time JWT enrollment token.
  2. Exchange the JWT for a long-lived identity bundle (JSON / PFX). The exchange happens once, on the machine that will hold the identity.
  3. Mount the bundle into the Ikanos deployment through the same binds mechanism Ikanos already uses for secrets — e.g. a Kubernetes Secret mounted as a file, or location: file:// against a sealed-secret-managed path.

The Ziti controller can revoke an identity at any time; the engine surfaces the next failed dial through isReady() == false on the Tunnel and a 503-equivalent on the consumed operation. Rotation is therefore atomic from Ikanos's point of view: drop the new identity into the bound location, restart the capability, and the next dial uses the new bundle.

One identity per capability is the canonical shape in Alpha 3. Multi-identity per capability is a future enhancement tracked in the Reverse Tunnel blueprint.

Fallback behaviour

tunnel.fallback controls what happens when the overlay is unavailable at dial time:

Value Behaviour Use when
fail (default) The consumed operation returns a 503-equivalent. /health/ready reflects the tunnel being down. Production — fail closed, never reach the public internet by accident
direct The engine dials baseUri directly over the public internet. Local development against a public stub of the private API. The ikanos-tunnel-fallback-direct-warns linter rule warns when used against anything other than a loopback baseUri.

The fail-closed default exists because a leaked direct fallback would silently exfiltrate a request that the developer thought was being tunnelled. Lint catches the obvious cases; the schema description documents the rest.

Observability — what surfaces in Ikanos

Unlike the sidecar pattern (where the tunnel is opaque to the engine), the embedded path surfaces tunnel state in the same places Ikanos already exposes its own health:

  • /health/ready — gated on Tunnel.isReady(). A capability is not ready until the Ziti context is Active.
  • /metrics — dial attempts, dial latency, and tunnel session state, tagged by capability and consumed namespace.
  • Traces — the Ziti dial is a child span of the consumed-operation span, so trace context propagates end-to-end through the overlay.

This is the single biggest operational advantage over the sidecar pattern: tunnel state is inside the engine's observability story, not next to it.


Path B — Sidecar pattern (for non-Ziti tunnels)

The sidecar pattern keeps working and is still the right choice in two cases:

  • You want FRP or Cloudflare Tunnel specifically (e.g. you already operate a Cloudflare Access tenant).
  • You cannot extend the JVM classpath in the Ikanos deployment — e.g. you ship a tightly-locked image and need the tunnel as pure infrastructure.

For an OpenZiti overlay you have a choice: use Path A (recommended) or run the standalone ziti-edge-tunnel as a sidecar. Path A removes one container, one identity-management surface, and the loopback indirection.

How the sidecar pattern works

The deployment topology has three pieces:

   PUBLIC NETWORK                                 │           PRIVATE NETWORK
   ┌─────────────────────────────┐                │     ┌─────────────────────────┐
   │ Ikanos engine               │                │     │ Internal API            │
   │  baseUri: http://127.0.0.1: │                │     │ (CRM, ERP, DB, …)       │
   │           8443              │                │     └────────────▲────────────┘
   └─────────────┬───────────────┘                │                  │ HTTP
                 │ HTTP (loopback)                │                  │
   ┌─────────────▼───────────────┐                │     ┌────────────┴────────────┐
   │ Tunnel sidecar (frpc /      │                │     │ Tunnel server / agent   │
   │ cloudflared / ziti-tunneler)│ ◄── tunnel ────│─────│ (frps / cloudflared /   │
   └─────────────────────────────┘   (outbound    │     │ ziti-edge-tunnel)       │
                                     from private)     └─────────────────────────┘

Key invariants:

  1. The private-side process always dials out. The firewall only needs to permit outbound traffic to a known relay or controller.
  2. The capability YAML points at a loopback endpoint. consumes.baseUri is http://127.0.0.1:<port> — the sidecar owns the mapping to the real private host.
  3. Authentication still runs end-to-end on top of the tunnel, same as Path A.

Recipe 1 — FRP (Fast Reverse Proxy)

FRP is an Apache 2.0 reverse proxy with a self-hostable control plane (frps) and a small client (frpc). Recommended sidecar default when you need a non-Ziti tunnel.

Components

Side Process Role
Public (next to Ikanos) frpc Listens on 127.0.0.1:<port>, forwards each request through the tunnel
Public (relay) frps Accepts the outbound connection from the private side and brokers requests
Private frpc Connects outbound to frps, dials the internal API on each request

docker-compose.yml

version: "3.9"
services:
  ikanos:
    image: ghcr.io/naftiko/ikanos:1.0.0-alpha3
    ports:
      - "8081:8081"     # REST/MCP exposure
    volumes:
      - ./capability.yaml:/app/capability.yaml:ro
    depends_on:
      frpc:
        condition: service_healthy

  frpc:
    image: snowdreamtech/frpc:latest
    network_mode: "service:ikanos"   # share Ikanos' loopback
    volumes:
      - ./frpc.toml:/etc/frp/frpc.toml:ro
    healthcheck:
      test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1:7400/healthz"]
      interval: 5s
      timeout: 2s
      retries: 12

network_mode: "service:ikanos" puts frpc and Ikanos on the same loopback, so baseUri: http://127.0.0.1:8443 resolves to the sidecar's local listener.

frpc.toml (public side)

serverAddr = "frps.example.com"
serverPort = 7000
auth.method = "token"
auth.token = "{{REPLACE_WITH_TOKEN}}"

# Expose a local listener that maps to the private CRM
[[visitors]]
name = "crm"
type = "stcp"
serverName = "crm-private"
secretKey = "{{REPLACE_WITH_SECRET}}"
bindAddr = "127.0.0.1"
bindPort = 8443

The private-side frpc mirrors this with type = "stcp", name = "crm-private", the matching secretKey, and localIP / localPort pointing at the internal API. See the FRP documentation for the full server + private-side config.

Security profile

  • Authentication: shared token between every frpc and frps; per-tunnel secretKey for stcp visitors.
  • Encryption: optional mTLS on frpc ↔ frps. For HTTPS upstream, the application-layer TLS handshake still runs end-to-end inside the tunnel — frpc only sees ciphertext.
  • Granularity: per-tunnel keys. A leaked secretKey only exposes the matching stcp mapping, not the whole network.

Recipe 2 — Cloudflare Tunnel

Cloudflare Tunnel (cloudflared) is the lowest-setup option if you accept a SaaS control plane. Cloudflare brokers the connection; you do not run a relay server.

Components

Side Process Role
Public (next to Ikanos) cloudflared access tcp Opens a local TCP listener that proxies to a hostname routed via Cloudflare Access
Public (control plane) Cloudflare Access Identity-aware proxy that authenticates the public side and brokers the tunnel
Private cloudflared tunnel Dials outbound to Cloudflare's edge and registers as the origin for a hostname

docker-compose.yml

version: "3.9"
services:
  ikanos:
    image: ghcr.io/naftiko/ikanos:1.0.0-alpha3
    ports:
      - "8081:8081"
    volumes:
      - ./capability.yaml:/app/capability.yaml:ro
    depends_on:
      cloudflared:
        condition: service_started

  cloudflared:
    image: cloudflare/cloudflared:latest
    network_mode: "service:ikanos"
    command:
      - access
      - tcp
      - --hostname
      - crm.internal.example.com
      - --url
      - 127.0.0.1:8443
    environment:
      TUNNEL_SERVICE_TOKEN_ID:     "${CF_SERVICE_TOKEN_ID}"
      TUNNEL_SERVICE_TOKEN_SECRET: "${CF_SERVICE_TOKEN_SECRET}"

The private-side cloudflared tunnel run <UUID> is configured separately and points at the internal API. Authorization between the public sidecar and the tunnel is enforced by Cloudflare Access Service Tokens.

Security profile

  • Authentication: Cloudflare Access Service Tokens (or SSO / mTLS) on the public side; tunnel credentials on the private side. Both are issued and revocable from the Cloudflare dashboard.
  • Encryption: end-to-end TLS to Cloudflare's edge; mTLS optional on both legs.
  • Granularity: per-hostname. Service Tokens can be revoked individually.
  • Trade-off: the control plane is not self-hostable — you depend on Cloudflare. If air-gap or sovereignty is a requirement, prefer Path A or FRP.

Recipe 3 — Standalone OpenZiti tunneler

This recipe runs the standalone ziti-edge-tunnel as a sidecar instead of the embedded Path A. Use it only when you cannot extend the JVM classpath; otherwise prefer Path A, which uses the same overlay without the extra container.

Components

Side Process Role
Public (next to Ikanos) ziti-edge-tunnel run Loads a Ziti identity, intercepts a configured hostname, routes through the overlay
Public (control plane) Ziti controller + edge router(s) Brokers service authorization and connection routing
Private ziti-edge-tunnel run (host mode) Loads a different identity, hosts the internal API as a Ziti service

docker-compose.yml

version: "3.9"
services:
  ikanos:
    image: ghcr.io/naftiko/ikanos:1.0.0-alpha3
    ports:
      - "8081:8081"
    volumes:
      - ./capability.yaml:/app/capability.yaml:ro
    depends_on:
      ziti-tunnel:
        condition: service_started

  ziti-tunnel:
    image: openziti/ziti-edge-tunnel:latest
    network_mode: "service:ikanos"
    cap_add: ["NET_ADMIN"]
    devices: ["/dev/net/tun"]
    volumes:
      - ./ikanos-identity.json:/ziti-edge-tunnel/identity.json:ro
    command: ["run", "--identity", "/ziti-edge-tunnel/identity.json"]

Because ziti-edge-tunnel requires NET_ADMIN and /dev/net/tun, this recipe is the most container-runtime-sensitive of the three. Path A avoids both — the Ikanos JVM dials through the overlay using only socket-level integration.

Security profile

  • Authentication: x509-based identity enrollment per side; short-lived session credentials issued by the controller.
  • Encryption: end-to-end mTLS through every hop of the Ziti fabric. Application-layer TLS still runs end-to-end on top.
  • Granularity: per-service authorization in the controller.

Capability YAML for the sidecar pattern

Whichever sidecar you choose, the capability YAML looks the same — note the baseUri is 127.0.0.1, and there is no tunnel: block:

ikanos: "1.0.0-alpha3"

binds:
  - namespace: secrets
    location: env://
    keys:
      CRM_TOKEN: CRM_TOKEN

capability:
  consumes:
    - type: http
      namespace: crm
      description: |
        Internal CRM API, reached through a reverse-tunnel sidecar.
        The sidecar (frpc / cloudflared / ziti-edge-tunnel) is responsible for
        routing 127.0.0.1:8443 to the private CRM. From Ikanos' point of view
        this is a regular HTTP endpoint.
      baseUri: "http://127.0.0.1:8443"
      authentication:
        type: bearer
        token: "{{secrets.CRM_TOKEN}}"
      resources:
        customers:
          path: "/v1/customers/{id}"
          operations:
            get-customer:
              method: GET
              inputParameters:
                - { name: id, in: path, required: true }

Two things to notice:

  • baseUri uses http://127.0.0.1:<port> rather than the real internal hostname. The sidecar owns the mapping.
  • authentication: is unchanged. The bearer token authenticates the capability to the upstream API, end-to-end inside the tunnel.

Health checks and startup ordering

The sidecar must be ready before Ikanos starts polling consumed endpoints, otherwise the first health check on /health/ready may fail. Use one of the following:

  • Docker Composedepends_on: { sidecar: { condition: service_healthy } } plus a healthcheck: block on the sidecar (FRP example above).
  • Kubernetes — model the sidecar as a regular sidecar container with a readinessProbe. The pod is not considered ready until both containers report ready.
  • systemd — put Requires= and After= on the Ikanos unit pointing at the tunnel unit.

In the sidecar pattern, the Ikanos Control Port reflects readiness of the consumed endpoint, not the tunnel itself — there is no Ikanos-side visibility into the sidecar's health. If you need tunnel state to surface in /health/ready and /metrics, use Path A.


Choosing a path

Question Answer
You want a Ziti overlay and you control the Ikanos image Path A
You want FRP or Cloudflare Tunnel Path B (Recipe 1 or 2)
You want a Ziti overlay but cannot extend the Ikanos classpath Path B (Recipe 3)
You want tunnel state in /health/ready, /metrics, and traces Path A — sidecars are opaque to the engine
You want the tunnel declared in the capability contract Path Atunnel: is part of the YAML
You want one less container per deployment Path A

When in doubt: Path A. The sidecar pattern is a supported escape hatch, not the default.


Security trade-offs

Property Path A (embedded Ziti) Path B — FRP Path B — Cloudflare Path B — Ziti tunneler
Self-hostable control plane Yes Yes No (SaaS) Yes
Short-lived credentials Yes (Ziti enrollment) Optional (TLS rotation) Yes (Service Tokens) Yes (Ziti enrollment)
Per-service authorization Per Ziti service Per stcp secretKey Per hostname / Access policy Per Ziti service
Identity per capability Yes (one identity per consumes.http) Token-shared Service Token per sidecar Identity per sidecar
Tunnel state in engine observability Yes No No No
Sidecar processes 0 1 1 1
Setup complexity Low (if controller already exists) Low Lowest Medium
Air-gap-friendly Yes Yes No Yes

All paths preserve the no-inbound-firewall-rule property — that is the whole point. They differ on credential lifecycle, blast radius of a leak, and where tunnel state shows up operationally.


Troubleshooting

Symptom Likely cause Fix
Capability fails to start with No Tunnel provider for type 'ziti' The ikanos-tunnel-ziti jar is not on the classpath Add the dependency / volume mount described in Adding the tunnel module to your deployment
Capability starts but /health/ready returns 503 The Ziti context is not yet Active — controller unreachable or identity expired Check controller logs; verify outbound 443 from the public side; re-enroll the identity
Consumed operations fail with Ziti dial failed after a previously-healthy period Identity revoked or service authorization changed Inspect controller; rotate the identity in the bound location and restart the capability
Polychro warns ikanos-tunnel-identity-must-bind tunnel.identity is a literal path instead of a Mustache reference Move the identity into binds and reference it as {{secrets.ZITI_IDENTITY}}
Polychro warns ikanos-tunnel-fallback-direct-warns fallback: direct on a non-loopback baseUri — would dial the public internet on tunnel failure Either change baseUri to loopback (dev only) or set fallback: fail
(Sidecar) /health/ready returns 503 at startup, then recovers Ikanos started before the sidecar Add a healthcheck: + depends_on: condition: service_healthy (Compose) or readinessProbe (k8s) on the sidecar
(Sidecar) Consumed operations fail with Connection refused on 127.0.0.1 Sidecar not listening on the expected loopback / port Verify the sidecar's bind address; ensure network_mode: "service:ikanos" so loopback is shared
(Sidecar) Consumed operations fail with UnknownHostException baseUri uses a hostname the engine cannot resolve Point baseUri at 127.0.0.1:<port> (Path B) or migrate to Path A and keep the real hostname
Intermittent 502 / Connection reset mid-request Tunnel re-establishing Check tunnel / Ziti logs; verify outbound 443 (or the FRP port) is reliably open from the private side
Authentication errors despite a correct bearer token TLS terminated at the sidecar (HTTP baseUri against an HTTPS upstream) Use the same scheme end-to-end, or migrate to Path A where baseUri keeps its real https:// scheme