Ikanos — Guide - Reverse Tunnel for Private APIs¶
Table of Contents¶
- Overview
- When to use this guide
- Path A — Embedded OpenZiti (built-in, recommended)
- Path B — Sidecar pattern (for non-Ziti tunnels)
- Choosing a path
- Security trade-offs
- Troubleshooting
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.
Path A — Embedded OpenZiti (built-in, recommended)¶
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-zitijar on the classpath, add atunnel:block toconsumes.http, mount a Ziti identity throughbinds, and that's it.baseUrikeeps 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:
- The engine dials the overlay — no extra container, no loopback hop. The
TunnelSPI (io.ikanos.engine.consumes.tunnel.Tunnel) replaces the defaultHttpClientsocket factory when atunnel:block is declared. - 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.
baseUriis the real hostname.https://crm.internalresolves through the Zitiintercept.v1config, not DNS. No127.0.0.1placeholder, no synthetic hosts file.- 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-zitijar at runtime when the engine actually dials.
Identity provisioning and rotation¶
Each capability gets its own Ziti identity. The standard flow:
- Enroll the identity on the Ziti controller — produces a one-time JWT enrollment token.
- Exchange the JWT for a long-lived identity bundle (JSON / PFX). The exchange happens once, on the machine that will hold the identity.
- Mount the bundle into the Ikanos deployment through the same
bindsmechanism Ikanos already uses for secrets — e.g. a Kubernetes Secret mounted as a file, orlocation: 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 onTunnel.isReady(). A capability is not ready until the Ziti context isActive./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:
- The private-side process always dials out. The firewall only needs to permit outbound traffic to a known relay or controller.
- The capability YAML points at a loopback endpoint.
consumes.baseUriishttp://127.0.0.1:<port>— the sidecar owns the mapping to the real private host. - 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"putsfrpcand Ikanos on the same loopback, sobaseUri: http://127.0.0.1:8443resolves 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
frpcandfrps; per-tunnelsecretKeyforstcpvisitors. - Encryption: optional mTLS on
frpc ↔ frps. For HTTPS upstream, the application-layer TLS handshake still runs end-to-end inside the tunnel —frpconly sees ciphertext. - Granularity: per-tunnel keys. A leaked
secretKeyonly exposes the matchingstcpmapping, 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-tunnelrequiresNET_ADMINand/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:
baseUriuseshttp://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 Compose —
depends_on: { sidecar: { condition: service_healthy } }plus ahealthcheck: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=andAfter=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 A — tunnel: 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 |