Skip to content

Commit 89212da

Browse files
committed
feat: add shared tunnel server for cloud deployments
- Add TunnelServer class for single-port tunnel multiplexing - Add --tunnel-port CLI option to enable shared tunnel mode - Update TunnelAgent to optionally use shared TunnelServer - Client sends ID on connect when sharedTunnel flag is set - Server returns sharedTunnel: true and tunnelPort in response - Document cloud deployment configuration in README This fixes 404 errors when running pipenet server in containerized environments like fly.io where only specific ports are exposed.
1 parent 2fd62d8 commit 89212da

9 files changed

Lines changed: 256 additions & 15 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ npx pipenet server --port 3000 --domain tunnel.example.com
9494
# With multiple domains
9595
npx pipenet server --port 3000 --domain tunnel.example.com --domain tunnel.example.org
9696

97+
# For cloud deployments (single tunnel port mode)
98+
npx pipenet server --port 3000 --tunnel-port 3001 --domain tunnel.example.com
99+
97100
# Or programmatically
98101
```
99102

@@ -105,8 +108,14 @@ const server = createServer({
105108
secure: false, // Optional: require HTTPS
106109
landing: 'https://pipenet.dev', // Optional: landing page URL
107110
maxTcpSockets: 10, // Optional: max sockets per client
111+
tunnelPort: 3001, // Optional: shared tunnel port for cloud deployments
108112
});
109113

114+
// Start tunnel server if using shared tunnel port
115+
if (server.tunnelServer) {
116+
await server.tunnelServer.listen(3001);
117+
}
118+
110119
server.listen(3000, () => {
111120
console.log('pipenet server listening on port 3000');
112121
});
@@ -118,13 +127,45 @@ server.listen(3000, () => {
118127
- `secure` (boolean) Require HTTPS connections
119128
- `landing` (string) URL to redirect root requests to
120129
- `maxTcpSockets` (number) Maximum number of TCP sockets per client (default: 10)
130+
- `tunnelPort` (number) Shared tunnel port for cloud deployments (enables single-port mode)
121131

122132
### Server API Endpoints
123133

124134
- `GET /api/status` - Server status and tunnel count
125135
- `GET /api/tunnels/:id/status` - Status of a specific tunnel
126136
- `GET /:id` - Request a new tunnel with the specified ID
127137

138+
### Cloud Deployments
139+
140+
When deploying pipenet server to cloud platforms like fly.io, Docker, or Kubernetes, you typically can only expose a limited number of ports. By default, pipenet creates a random TCP port for each tunnel client, which doesn't work well in these environments.
141+
142+
Use the `--tunnel-port` option to enable single-port mode, where all tunnel clients connect to a single shared port:
143+
144+
```bash
145+
# fly.io example
146+
pipenet server --port 8080 --tunnel-port 8081 --domain tunnel.example.com --secure
147+
```
148+
149+
Then expose both ports in your deployment configuration. For fly.io:
150+
151+
```toml
152+
[[services]]
153+
internal_port = 8080
154+
protocol = "tcp"
155+
[[services.ports]]
156+
port = 80
157+
handlers = ["http"]
158+
[[services.ports]]
159+
port = 443
160+
handlers = ["http", "tls"]
161+
162+
[[services]]
163+
internal_port = 8081
164+
protocol = "tcp"
165+
[[services.ports]]
166+
port = 8081
167+
```
168+
128169
## Why pipenet?
129170

130171
pipenet was developed by [glama.ai](https://glama.ai) to enable local [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers to connect with remote AI clients (e.g., to give AI assistants access to your local file system).

src/Tunnel.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface ServerResponse {
2626
maxConnCount?: number;
2727
message?: string;
2828
port: number;
29+
sharedTunnel?: boolean;
2930
url: string;
3031
}
3132

@@ -43,6 +44,7 @@ interface TunnelInfo extends TunnelClusterOptions {
4344
remoteHost: string;
4445
remoteIp: string;
4546
remotePort: number;
47+
sharedTunnel?: boolean;
4648
url: string;
4749
}
4850

@@ -141,7 +143,7 @@ export class Tunnel extends EventEmitter {
141143
}
142144

143145
private _getInfo(body: ServerResponse): TunnelInfo {
144-
const { cachedUrl, id, ip, maxConnCount, port, url } = body;
146+
const { cachedUrl, id, ip, maxConnCount, port, sharedTunnel, url } = body;
145147
const { host, localHost, port: localPort } = this.opts;
146148
const { allowInvalidCert, localCa, localCert, localHttps, localKey } = this.opts;
147149

@@ -159,6 +161,7 @@ export class Tunnel extends EventEmitter {
159161
remoteHost: new URL(host!).hostname,
160162
remoteIp: ip,
161163
remotePort: port,
164+
sharedTunnel,
162165
url,
163166
};
164167
}

src/TunnelCluster.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface TunnelClusterOptions {
2222
remoteHost?: string;
2323
remoteIp?: string;
2424
remotePort?: number;
25+
sharedTunnel?: boolean;
2526
url?: string;
2627
}
2728

@@ -158,6 +159,11 @@ export class TunnelCluster extends EventEmitter {
158159
});
159160

160161
remote.once('connect', () => {
162+
// Send client ID as the first message for shared tunnel server mode
163+
if (opt.sharedTunnel && opt.name) {
164+
log('sending client ID for shared tunnel: %s', opt.name);
165+
remote.write(opt.name + '\n');
166+
}
161167
this.emit('open', remote);
162168
connLocal();
163169
});

src/cli.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface ServerOptions {
2727
'max-sockets'?: number;
2828
port: number;
2929
secure?: boolean;
30+
'tunnel-port'?: number;
3031
}
3132

3233
async function runClient(opts: ClientOptions) {
@@ -75,14 +76,21 @@ async function runClient(opts: ClientOptions) {
7576
});
7677
}
7778

78-
function runServer(opts: ServerOptions) {
79+
async function runServer(opts: ServerOptions) {
7980
const server = createServer({
8081
domains: opts.domain,
8182
landing: opts.landing,
8283
maxTcpSockets: opts['max-sockets'],
8384
secure: opts.secure,
85+
tunnelPort: opts['tunnel-port'],
8486
});
8587

88+
// Start the tunnel server if configured
89+
if (server.tunnelServer && opts['tunnel-port']) {
90+
await server.tunnelServer.listen(opts['tunnel-port'], opts.address);
91+
console.log('tunnel server listening on port %d', opts['tunnel-port']);
92+
}
93+
8694
const listenCallback = () => {
8795
console.log('pipenet server listening on port %d', opts.port);
8896
if (opts.address) {
@@ -101,6 +109,9 @@ function runServer(opts: ServerOptions) {
101109

102110
process.on('SIGINT', () => {
103111
console.log('shutting down server...');
112+
if (server.tunnelServer) {
113+
server.tunnelServer.close();
114+
}
104115
server.close(() => {
105116
process.exit(0);
106117
});
@@ -210,6 +221,11 @@ yargs(hideBin(process.argv))
210221
default: 10,
211222
describe: 'Maximum number of TCP sockets per client',
212223
type: 'number',
224+
})
225+
.option('tunnel-port', {
226+
alias: 't',
227+
describe: 'Port for tunnel connections (enables single-port mode for cloud deployments)',
228+
type: 'number',
213229
});
214230
},
215231
(argv) => {

src/server/ClientManager.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { hri } from 'human-readable-ids';
33

44
import { Client } from './Client.js';
55
import { TunnelAgent } from './TunnelAgent.js';
6+
import type { TunnelServer } from './TunnelServer.js';
67

78
export interface ClientManagerOptions {
89
maxTcpSockets?: number;
10+
tunnelServer?: TunnelServer;
911
}
1012

1113
export interface NewClientInfo {
@@ -47,6 +49,7 @@ export class ClientManager {
4749
const agent = new TunnelAgent({
4850
clientId: id,
4951
maxTcpSockets: 10,
52+
tunnelServer: this.opt.tunnelServer,
5053
});
5154

5255
const client = new Client({ agent, id });

src/server/TunnelAgent.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Agent, ClientRequestArgs } from 'http';
33
import net from 'net';
44
import { Duplex } from 'stream';
55

6+
import type { TunnelServer } from './TunnelServer.js';
7+
68
const DEFAULT_MAX_SOCKETS = 10;
79

810
export interface TunnelAgentListenInfo {
@@ -12,6 +14,7 @@ export interface TunnelAgentListenInfo {
1214
export interface TunnelAgentOptions {
1315
clientId?: string;
1416
maxTcpSockets?: number;
17+
tunnelServer?: TunnelServer;
1518
}
1619

1720
export interface TunnelAgentStats {
@@ -23,21 +26,28 @@ type CreateConnectionCallback = (err: Error | null, socket: Duplex) => void;
2326
export class TunnelAgent extends Agent {
2427
public started: boolean;
2528
private availableSockets: net.Socket[];
29+
private clientId?: string;
2630
private closed: boolean;
2731
private connectedSockets: number;
2832
private log: debug.Debugger;
2933
private maxTcpSockets: number;
30-
private server: net.Server;
34+
private server?: net.Server;
35+
private tunnelServer?: TunnelServer;
3136
private waitingCreateConn: CreateConnectionCallback[];
3237

3338
constructor(options: TunnelAgentOptions = {}) {
3439
super({ keepAlive: true, maxFreeSockets: 1 });
3540
this.availableSockets = [];
3641
this.waitingCreateConn = [];
42+
this.clientId = options.clientId;
3743
this.log = debug(`lt:TunnelAgent[${options.clientId}]`);
3844
this.connectedSockets = 0;
3945
this.maxTcpSockets = options.maxTcpSockets || DEFAULT_MAX_SOCKETS;
40-
this.server = net.createServer();
46+
this.tunnelServer = options.tunnelServer;
47+
// Only create a local server if no shared tunnel server is provided
48+
if (!this.tunnelServer) {
49+
this.server = net.createServer();
50+
}
4151
this.started = false;
4252
this.closed = false;
4353
}
@@ -61,14 +71,35 @@ export class TunnelAgent extends Agent {
6171
}
6272

6373
destroy(): void {
64-
this.server.close();
74+
if (this.tunnelServer && this.clientId) {
75+
this.tunnelServer.unregisterHandler(this.clientId);
76+
}
77+
if (this.server) {
78+
this.server.close();
79+
}
6580
super.destroy();
6681
}
6782

6883
listen(): Promise<TunnelAgentListenInfo> {
6984
if (this.started) throw new Error('already started');
7085
this.started = true;
7186

87+
// If using shared tunnel server, register handler and return port 0
88+
// (the actual port is the tunnel server's port, handled externally)
89+
if (this.tunnelServer && this.clientId) {
90+
this.tunnelServer.registerHandler(this.clientId, (socket) => {
91+
this._onConnection(socket);
92+
});
93+
this.log('registered with shared tunnel server');
94+
// Return port 0 to indicate shared tunnel server mode
95+
return Promise.resolve({ port: 0 });
96+
}
97+
98+
// Legacy mode: create our own TCP server
99+
if (!this.server) {
100+
throw new Error('No server available');
101+
}
102+
72103
this.server.on('close', this._onClose.bind(this));
73104
this.server.on('connection', this._onConnection.bind(this));
74105
this.server.on('error', (err: NodeJS.ErrnoException) => {
@@ -77,8 +108,8 @@ export class TunnelAgent extends Agent {
77108
});
78109

79110
return new Promise((resolve) => {
80-
this.server.listen(() => {
81-
const addr = this.server.address() as net.AddressInfo;
111+
this.server!.listen(() => {
112+
const addr = this.server!.address() as net.AddressInfo;
82113
this.log('tcp server listening on port: %d', addr.port);
83114
resolve({ port: addr.port });
84115
});

0 commit comments

Comments
 (0)