npm package: @kadi.build/tunnel-services
Description: Unified tunnel management for exposing local ports via ngrok, serveo, localtunnel, KĀDI, and more
Latest version: 1.1.0

# @kadi.build/tunnel-services

> Unified tunnel service manager for exposing local ports to the internet. Supports 6 tunnel providers with automatic failover, KĀDI as the default primary service.

## Features

- 🚇 **6 Tunnel Providers** — KĀDI, Serveo, ngrok, LocalTunnel, Pinggy, localhost.run
- 🔄 **Automatic Fallback** — Seamlessly switches to the next provider on failure
- 🎯 **KĀDI-First** — KĀDI is the default primary tunnel service
- 📡 **Event-Driven** — Rich event system for monitoring tunnel lifecycle
- 🔧 **Diagnostic Tools** — Built-in diagnostics for troubleshooting
- 🧩 **Pluggable** — Register custom tunnel services easily
- 📦 **Zero Config** — Works out of the box with sensible defaults

## Installation

```bash
npm install @kadi.build/tunnel-services
```

### Optional Dependencies

Install based on which providers you want to use:

```bash
# For ngrok support
npm install @ngrok/ngrok

# For localtunnel support  
npm install localtunnel

# SSH-based providers (serveo, pinggy, localhost.run, kadi) 
# require `ssh` on your PATH — no extra packages needed
```

## Quick Start

### One-Liner

```js
import { expose } from '@kadi.build/tunnel-services';

// Expose port 3000 to the internet
const url = await expose(3000);
console.log(`Public URL: ${url}`);
```

### With Tunnel Management

```js
import { createTunnel } from '@kadi.build/tunnel-services';

const tunnel = await createTunnel(3000, {
  service: 'kadi',         // or 'serveo', 'ngrok', 'localtunnel', 'pinggy', 'localhost.run'
  subdomain: 'my-app',     // optional
  autoFallback: true        // try next provider on failure
});

console.log(`Public URL: ${tunnel.publicUrl}`);
console.log(`Local Port: ${tunnel.localPort}`);
console.log(`Service:    ${tunnel.service}`);

// When done
await tunnel.close();
```

### Full Control with TunnelManager

```js
import { TunnelManager } from '@kadi.build/tunnel-services';

const manager = new TunnelManager({
  primaryService: 'kadi',
  fallbackServices: ['serveo', 'ngrok', 'localtunnel', 'pinggy'],
  autoFallback: true,
  maxConcurrentTunnels: 10,
  connectionTimeout: 30000
});

await manager.initialize();

// Listen for events
manager.on('tunnelCreated', ({ id, publicUrl, service }) => {
  console.log(`Tunnel ${id} created on ${service}: ${publicUrl}`);
});

manager.on('tunnelDestroyed', ({ id }) => {
  console.log(`Tunnel ${id} closed`);
});

manager.on('serviceFailed', ({ service, error }) => {
  console.warn(`${service} failed: ${error.message}, trying next...`);
});

// Create tunnels
const tunnel1 = await manager.createTunnel(3000);
const tunnel2 = await manager.createTunnel(8080, { service: 'ngrok' });

// Check status
console.log(manager.getStatus());

// Close specific tunnel
await manager.closeTunnel(tunnel1.id);

// Cleanup everything
await manager.cleanup();
```

## API Reference

### Factory Functions

#### `expose(port, service?)` → `Promise<string>`

Returns a public URL string. Simplest way to expose a port.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `number` | — | Local port to expose |
| `service` | `string` | `'kadi'` | Tunnel service to use |

#### `createTunnel(port, options?)` → `Promise<TunnelInfo>`

Creates a tunnel and returns a `TunnelInfo` object with a `close()` method.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `number` | — | Local port to expose |
| `options.service` | `string` | `'kadi'` | Preferred service |
| `options.subdomain` | `string` | — | Requested subdomain |
| `options.autoFallback` | `boolean` | `true` | Enable fallback |

### TunnelManager

The main orchestrator class. Extends `EventEmitter`.

#### Constructor Options

```js
new TunnelManager({
  primaryService: 'kadi',           // Default primary service
  fallbackServices: ['serveo', 'ngrok', 'localtunnel', 'pinggy'],
  autoFallback: true,               // Auto-fallback on failure
  maxConcurrentTunnels: 10,         // Max simultaneous tunnels
  connectionTimeout: 30000,         // Connection timeout (ms)
  ngrokAuthToken: '',               // Ngrok auth token
  kadiServer: '',                   // KĀDI tunnel server
  kadiMode: 'ssh'                   // 'ssh' or 'frpc'
})
```

#### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `initialize()` | `Promise<void>` | Discover and load services |
| `createTunnel(port, options?)` | `Promise<TunnelInfo>` | Create a new tunnel |
| `closeTunnel(id)` | `Promise<void>` | Close specific tunnel |
| `closeAllTunnels()` | `Promise<void>` | Close all active tunnels |
| `getStatus()` | `object` | Manager status + active tunnels |
| `getAvailableServices()` | `string[]` | List discovered services |
| `testService(name)` | `Promise<object>` | Test if a service is available |
| `runDiagnostics()` | `Promise<object>` | Run diagnostics on all services |
| `cleanup()` | `Promise<void>` | Full shutdown |

#### Events

| Event | Payload | Description |
|-------|---------|-------------|
| `tunnelCreated` | `TunnelInfo` | Tunnel successfully created |
| `tunnelDestroyed` | `{ id }` | Tunnel closed |
| `serviceFailed` | `{ service, error }` | Service failed, falling back |
| `error` | `Error` | Unrecoverable error |

### TunnelInfo Object

```js
{
  id: 'tunnel-abc123',          // Unique tunnel ID
  publicUrl: 'https://...',      // Public URL
  localPort: 3000,              // Local port being exposed
  service: 'kadi',              // Service that created it
  subdomain: 'my-app',         // Subdomain (if requested)
  createdAt: Date,              // Creation timestamp
  metadata: {}                  // Service-specific metadata
}
```

### BaseTunnelService

Abstract base class for all tunnel services. Extend this to create custom providers.

```js
import { BaseTunnelService } from '@kadi.build/tunnel-services';

class MyTunnelService extends BaseTunnelService {
  get name() { return 'my-tunnel'; }

  async connect(port, options = {}) {
    // Connect and return { url, port, ... }
  }

  async disconnect() {
    // Clean up
  }

  getStatus() {
    return { connected: this._connected, service: this.name };
  }
}
```

### Error Classes

| Error | Fallback? | Description |
|-------|-----------|-------------|
| `TunnelError` | — | Base tunnel error |
| `TransientTunnelError` | ✅ Yes | Temporary failure, try next service |
| `PermanentTunnelError` | ❌ No | Permanent failure, do not fallback |
| `CriticalTunnelError` | ❌ No | Critical system-level failure |
| `ConfigurationError` | ❌ No | Invalid configuration |
| `ServiceUnavailableError` | ✅ Yes | Service not available/installed |
| `ConnectionTimeoutError` | ✅ Yes | Connection timed out |
| `SSHUnavailableError` | ✅ Yes | SSH binary not found |
| `AuthenticationFailedError` | ❌ No | Authentication failed |

## Tunnel Providers

### KĀDI (default)

SSH or frpc-based tunnel to KĀDI infrastructure. Supports two modes:
- **SSH** (zero-dependency) — uses `ssh -R` through the frps SSH gateway
- **frpc** (enhanced) — uses the `frpc` binary with auto-reconnect

In `auto` mode (the default), frpc is used when the binary is detected on `$PATH`, otherwise SSH is used as a fallback.

Since v0.4.0, frpc mode connects through a WSS gateway on port 443 by default, which works reliably on enterprise/campus networks that block non-standard ports.

```js
import { KadiTunnelService } from '@kadi.build/tunnel-services';

const kadi = new KadiTunnelService({
  frp: {
    serverAddr: 'broker.kadi.build',
    serverPort: 7000,
    sshPort: 2200,
    token: '...',
    tunnelDomain: 'tunnel.kadi.build',
    mode: 'auto',                                    // 'auto' | 'ssh' | 'frpc'
    transport: 'wss',                                // 'wss' (default) | 'tcp'
    wssControlHost: 'tunnel-control.kadi.build'      // WSS gateway hostname
  },
  agentId: 'my-agent'
});
```

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `serverAddr` | Yes | — | frps server address |
| `serverPort` | No | `7000` | frps bind port (TCP fallback) |
| `sshPort` | No | `2200` | frps SSH gateway port |
| `token` | Yes | — | Authentication token |
| `tunnelDomain` | Yes | — | Base domain for tunnel URLs |
| `mode` | No | `'auto'` | `'auto'`, `'ssh'`, or `'frpc'` |
| `transport` | No | `'wss'` | frpc transport: `'wss'` (port 443) or `'tcp'` (direct) |
| `wssControlHost` | If `transport='wss'` | `''` | WSS gateway hostname |

**Environment Variables:** `KADI_TUNNEL_SERVER`, `KADI_TUNNEL_PORT`

### Serveo

Free SSH-based tunneling via serveo.net. No account required.

```js
await manager.createTunnel(3000, { service: 'serveo' });
```

**Requires:** `ssh` on PATH

### ngrok

Industry-standard tunnel service. Requires auth token for extended use.

```js
const manager = new TunnelManager({
  primaryService: 'ngrok',
  ngrokAuthToken: process.env.NGROK_AUTH_TOKEN
});
```

**Requires:** `@ngrok/ngrok` or legacy `ngrok` package  
**Environment Variables:** `NGROK_AUTH_TOKEN`

### LocalTunnel

Open-source tunneling via localtunnel.me.

```js
await manager.createTunnel(3000, { service: 'localtunnel' });
```

**Requires:** `localtunnel` npm package

### Pinggy

SSH-based tunneling via pinggy.io.

```js
await manager.createTunnel(3000, { service: 'pinggy' });
```

**Requires:** `ssh` on PATH

### localhost.run

SSH-based tunneling via localhost.run. No account required.

```js
await manager.createTunnel(3000, { service: 'localhost.run' });
```

**Requires:** `ssh` on PATH

## Fallback Order

When `autoFallback: true` (default), the manager tries services in this order:

1. **KĀDI** (primary)
2. **Serveo**
3. **ngrok**
4. **LocalTunnel**
5. **Pinggy**

If a service throws a `TransientTunnelError` or `ServiceUnavailableError`, the next service in the chain is attempted. `PermanentTunnelError` stops the fallback chain.

## Testing

```bash
# Run all tests
npm test

# Run specific test suite
npm run test:manager
npm run test:kadi
npm run test:ngrok
npm run test:serveo

# Integration tests (require credentials/tools)
NGROK_AUTH_TOKEN=xxx npm run test:ngrok
KADI_TUNNEL_SERVER=tunnel.kadi.build npm run test:kadi
```

## Configuration

As of v1.1.0, tunnel-services uses a **3-tier configuration resolution** pattern
that replaces the previous `.env`-only approach:

| Priority | Source | What it provides |
|----------|--------|------------------|
| 1 (highest) | `process.env` | Direct overrides at startup |
| 2 | **Vault** (`secrets.toml`) | Encrypted tokens (`KADI_TUNNEL_TOKEN`, `NGROK_AUTH_TOKEN`) via `secret-ability` |
| 3 | **`config.yml`** | Non-secret settings (server address, port, transport, etc.) — walks up from CWD |
| 4 (fallback) | `.env` file | Legacy fallback — walks up from CWD |

### config.yml

Place a `config.yml` in your project root (or any parent directory):

```yaml
tunnel:
  server_addr: broker.kadi.build
  tunnel_domain: tunnel.kadi.build
  server_port: 7000
  ssh_port: 2200
  mode: frpc
  transport: wss
  wss_control_host: tunnel-control.kadi.build
  agent_id: kadi
  # vault: tunnel                   # vault name (default)
  # vault_keys:
  #   kadi_token: KADI_TUNNEL_TOKEN  # key in vault
  #   ngrok_token: NGROK_AUTH_TOKEN  # key in vault
```

### Vault Secrets

Store tunnel tokens in the encrypted vault:

```bash
kadi secret set -v tunnel KADI_TUNNEL_TOKEN <your-token>
kadi secret set -v tunnel NGROK_AUTH_TOKEN <your-token>
```

### Environment Variables (overrides)

| Variable | Used By | Description |
|----------|---------|-------------|
| `KADI_TUNNEL_TOKEN` | KĀDI | Tunnel authentication token |
| `NGROK_AUTH_TOKEN` | ngrok | Authentication token |
| `KADI_TUNNEL_SERVER` | KĀDI | Tunnel server hostname |
| `KADI_TUNNEL_PORT` | KĀDI | Tunnel server port |
| `KADI_TUNNEL_TRANSPORT` | KĀDI | Transport mode (`wss`, `tcp`) |
| `DEBUG` | all | Debug logging (e.g., `kadi:tunnel:*`) |

### Programmatic Config Resolution

```js
import { resolveTunnelConfig } from '@kadi.build/tunnel-services';

const { config, secrets } = await resolveTunnelConfig({ kadiClient });
// config = { server_addr, tunnel_domain, server_port, ... }
// secrets = { kadi_token, ngrok_token }
```

## Container Usage — Installing `frpc`

When running inside a Docker container (e.g. via `kadi deploy`), the KĀDI tunnel service needs the **frpc** binary to be available at build time. Alpine-based images (like `node:22-alpine`) do not include it by default.

Add the following lines to the `run` array in your `agent.json` build config:

```json
"run": [
  "apk add --no-cache curl openssh-client",
  "FRPC_VERSION=0.61.1 && wget -qO /tmp/frpc.tar.gz https://github.com/fatedier/frp/releases/download/v${FRPC_VERSION}/frp_${FRPC_VERSION}_linux_amd64.tar.gz && tar -xzf /tmp/frpc.tar.gz -C /tmp && mv /tmp/frp_${FRPC_VERSION}_linux_amd64/frpc /usr/local/bin/frpc && chmod +x /usr/local/bin/frpc && rm -rf /tmp/frpc.tar.gz /tmp/frp_${FRPC_VERSION}_linux_amd64"
]
```

**What these lines do:**

1. **`apk add --no-cache curl openssh-client`** — Installs `curl` and the SSH client (needed for SSH-mode fallback).
2. **`FRPC_VERSION=0.61.1 && wget …`** — Downloads the frpc binary (v0.61.1) from the official [fatedier/frp](https://github.com/fatedier/frp) releases, extracts it to `/usr/local/bin/frpc`, and cleans up temp files.

> **Note:** For non-Alpine images (Debian/Ubuntu-based), replace `apk add` with `apt-get update && apt-get install -y curl openssh-client`.

### Real-World Examples

See how `arcadedb-ability` and `backup-ability` include these lines in their `agent.json` build configs:

- [`arcadedb-ability/agent.json`](../arcadedb-ability/agent.json) — lines in the `build.default.run` array
- [`backup-ability/agent.json`](../backup-ability/agent.json) — lines in the `build.default.run` and `build.linux.run` arrays

## Troubleshooting

### Tunnel Not Being Created

If your deployed container fails to establish a KĀDI tunnel, the most common cause is a **missing `frpc` binary**. Symptoms include:

- Tunnel creation silently fails or times out
- Logs show `frpc: not found` or the service falls back to SSH mode unexpectedly
- The agent connects to the broker but is not reachable via its tunnel URL

**Fix:** Ensure the frpc installation lines above are in your `agent.json` `build.*.run` array, then rebuild the container image:

```bash
kadi build
```

You can verify frpc is installed correctly by running inside the container:

```bash
frpc --version
```

This should output `frpc version 0.61.1` (or whichever version you installed).

### Other Common Issues

| Symptom | Likely Cause | Fix |
|---------|-------------|-----|
| `frpc: not found` | frpc not installed in the container image | Add the `run` lines above and rebuild |
| Tunnel times out | Network/firewall blocking port 7000 | Set `KADI_TUNNEL_TRANSPORT=wss` to use port 443 instead |
| SSH fallback instead of frpc | frpc binary missing from `$PATH` | Install frpc (see above) |
| `Permission denied` on frpc | Binary not executable | Ensure `chmod +x /usr/local/bin/frpc` is in your build |

## Requirements

- Node.js >= 18.0.0
- SSH on PATH (for serveo, pinggy, localhost.run, kadi)
- Optional: `@ngrok/ngrok`, `localtunnel` npm packages
- **For containers:** `frpc` binary in `$PATH` (see [Container Usage](#container-usage--installing-frpc) above)

## License

MIT © KĀDI