pinggy 0.5.5 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +596 -0
- package/README.md +3 -3
- package/dist/{chunk-UX64XCC2.js → chunk-HN4YLM7U.js} +14 -1
- package/dist/{chunk-NBR2L6LW.js → chunk-OH6FSLYF.js} +21 -3
- package/dist/{daemonChild-ZACX7V6J.js → daemonChild-3ONQO2XI.js} +2 -2
- package/dist/index.cjs +32 -1
- package/dist/index.js +2 -2
- package/dist/{main-KG3Q4LXI.js → main-WXFARRMN.js} +2 -2
- package/package.json +2 -2
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
x# Pinggy CLI Architecture
|
|
2
|
+
|
|
3
|
+
Reference for developers and AI agents working on the `@pinggy/cli` codebase. Covers the two-process daemon model, IPC, state, session ownership, and module layout introduced on the `feat/daemon` branch.
|
|
4
|
+
|
|
5
|
+
For domain language (Tunnel, Daemon, Client, Origin, Foreground mode, etc.) see `CONTEXT.md`. For project rules and English style see `CLAUDE.md`. This file documents how the pieces fit together.
|
|
6
|
+
|
|
7
|
+
## 1. The two-process model
|
|
8
|
+
|
|
9
|
+
The `pinggy` binary has three execution modes, dispatched in `src/main.ts`:
|
|
10
|
+
|
|
11
|
+
| Mode | Trigger | Entry | Purpose |
|
|
12
|
+
| -------------------- | -------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| Daemon child | `--_daemon-child` flag | `runDaemonChild()` in `src/daemon/daemonChild.ts` | Long-running background process. Owns the `TunnelManager` and the SDK. |
|
|
14
|
+
| Subcommand | First arg in `SUBCOMMANDS` set | `handleSubcommand()` in `src/cli/subcommands.ts` | Short-lived CLI invocation:`config`, `start`, `stop`, `ps`, `attach`, `daemon`, `logs`, `log`, `restart`. |
|
|
15
|
+
| Legacy single-tunnel | No subcommand, has flags | `buildAndStartTunnel()` in `src/cli/buildAndStartTunnel.ts` | Backwards-compatible single-shot tunnel for flags like `-l 3000` or `-R0:...`. |
|
|
16
|
+
|
|
17
|
+
The CLI never opens an SDK tunnel itself. Every tunnel is created inside the daemon. CLI processes talk to the daemon over HTTP + WebSocket on `127.0.0.1`.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
A given user has **one** daemon. Whichever client (CLI or App) finds no live daemon spawns one; later clients discover it via `daemon.json`.
|
|
22
|
+
|
|
23
|
+
## 2. Filesystem layout
|
|
24
|
+
|
|
25
|
+
State lives under `getPinggyConfigDir()` (`src/utils/configDir.ts`):
|
|
26
|
+
|
|
27
|
+
- Linux: `$XDG_CONFIG_HOME/pinggy` (default `~/.config/pinggy`)
|
|
28
|
+
- macOS: `~/.config/pinggy`
|
|
29
|
+
- Windows: `%APPDATA%/pinggy`
|
|
30
|
+
|
|
31
|
+
Logs live under `getPinggyLogDir()`:
|
|
32
|
+
|
|
33
|
+
- Linux: `$XDG_STATE_HOME/pinggy-cli/logs`
|
|
34
|
+
- macOS: `~/Library/Logs/Pinggy-CLI`
|
|
35
|
+
- Windows: `%LOCALAPPDATA%/Pinggy-CLI/Logs`
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
<configDir>/
|
|
39
|
+
├── daemon.json Daemon discovery: {pid, port, startedAt}
|
|
40
|
+
├── daemon-state.json Active tunnel snapshot for crash recovery
|
|
41
|
+
└── tunnels/
|
|
42
|
+
└── <name>_<configId>.json Saved tunnel configs (from `pinggy config save`)
|
|
43
|
+
|
|
44
|
+
<logDir>/
|
|
45
|
+
├── daemon.log Daemon process log (rolled)
|
|
46
|
+
└── tunnels/
|
|
47
|
+
└── <origin>__<name>__<tunnelId>.log Per-tunnel logs (rolled 10MB×3)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Helpers live in `src/utils/configDir.ts`: `getDaemonInfoPath`, `getDaemonLogPath`, `getTunnelLogPath`, `getTunnelConfigDir`.
|
|
51
|
+
|
|
52
|
+
## 3. Daemon discovery and spawn
|
|
53
|
+
|
|
54
|
+
Every CLI invocation that touches a tunnel asks one question first: is there a live daemon to talk to? `getDaemonInfo()` answers it by reading `daemon.json` and validating the recorded PID with `process.kill(pid, 0)`. Signal 0 never delivers anything; it just throws when the target process is gone, which is exactly the liveness probe we need. A missing file, malformed JSON, or a dead PID all collapse to the same result: return `null`, and let `ensureDaemonRunning()` spawn a fresh child.
|
|
55
|
+
|
|
56
|
+
The spawn path is deliberately one-way. The CLI fires off a detached child with the `--_daemon-child` flag and then polls `daemon.json` until the child writes it. The poll budget is 8 seconds at 200ms intervals; a timeout almost always means the SDK failed to load inside the child, in which case the stderr captured from the spawned process is surfaced in the thrown error. Atomic writes of `daemon.json` (write to `.tmp`, then `fs.renameSync`) keep concurrent readers from ever seeing a half-written file.
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
CLI starts
|
|
60
|
+
│
|
|
61
|
+
▼
|
|
62
|
+
getDaemonInfo() ─── reads daemon.json
|
|
63
|
+
│
|
|
64
|
+
├─ file missing ──▶ return null
|
|
65
|
+
├─ malformed ──▶ return null
|
|
66
|
+
├─ PID dead (kill -0 fails)──▶ unlink daemon.json, return null
|
|
67
|
+
└─ PID alive ──▶ return DaemonInfo
|
|
68
|
+
│
|
|
69
|
+
▼
|
|
70
|
+
has DaemonInfo? ─── yes ──▶ connect IPCClient(port)
|
|
71
|
+
│
|
|
72
|
+
no
|
|
73
|
+
▼
|
|
74
|
+
startDaemon()
|
|
75
|
+
│
|
|
76
|
+
├─ spawn detached child with --_daemon-child
|
|
77
|
+
│ Unix: spawn(execPath, [argv[1], "--_daemon-child"], {detached, stdio: pipe-stderr})
|
|
78
|
+
│ Windows: same + windowsHide:true
|
|
79
|
+
│ Electron: calls ensuredaemon() which internally starts the daemon server if no daemon is there
|
|
80
|
+
│
|
|
81
|
+
├─ child.unref()
|
|
82
|
+
│
|
|
83
|
+
└─ pollForDaemonInfo(8000ms, interval 200ms)
|
|
84
|
+
│
|
|
85
|
+
├─ daemon.json appears ──▶ return DaemonInfo
|
|
86
|
+
├─ child exits early ──▶ throw with stderr
|
|
87
|
+
└─ timeout ──▶ throw "Daemon failed to start within timeout"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Implementation: `src/daemon/daemonManager.ts`.
|
|
91
|
+
|
|
92
|
+
The race between two CLIs invoked at the same moment is bounded but not eliminated: both may observe no `daemon.json`, both may spawn a child. Whichever child's atomic write lands last wins the file; the other daemon is reachable via its port but unreachable through discovery, and dies when its IPC server is closed or its parent CLI exits. In practice this is rare enough that we accept it; tightening it would require a file lock and adds more complexity than it removes.
|
|
93
|
+
|
|
94
|
+
## 4. Daemon child startup sequence
|
|
95
|
+
|
|
96
|
+
`runDaemonChild()` runs once per daemon lifetime, inside the spawned child process. The step order matters: signal handlers must register before any work that could throw, the IPC port must be open before `daemon.json` is written, and crash recovery must run before auto-start so a restored tunnel never races a freshly-spawned auto-start one.
|
|
97
|
+
|
|
98
|
+
`runDaemonChild()` in `src/daemon/daemonChild.ts`:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
1. ensurePinggyConfigDir(), ensurePinggyLogDir()
|
|
102
|
+
2. Configure winston logger → file: daemon.log, stdout: false
|
|
103
|
+
3. Create TunnelManager singleton
|
|
104
|
+
4. Create IPCServer (HTTP + WS, port not yet bound)
|
|
105
|
+
5. Create SessionTracker, wire onSessionDisconnect
|
|
106
|
+
6. Register signal handlers: SIGTERM, SIGINT, exit, uncaughtException
|
|
107
|
+
7. ipcServer.listen() → returns OS-assigned port
|
|
108
|
+
8. Write daemon.json atomically (write to .tmp, rename)
|
|
109
|
+
9. restoreCrashedTunnels() — replay detached tunnels from daemon-state.json
|
|
110
|
+
10. Start auto-start tunnels (getAutoStartConfigs())
|
|
111
|
+
11. Log "Daemon ready"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Step 8 is the contract the rest of the system depends on: once `daemon.json` exists with a live PID, the IPC server is already accepting connections. CLIs that read the file can immediately POST or open a WebSocket; there is no "warming up" window. Steps 9 and 10 may take time when the Pinggy edge is slow, so "Daemon ready" only means the daemon is reachable, not that every tunnel is live. Callers that need tunnel readiness must poll `/tunnels` or subscribe to `url_ready` over the WebSocket.
|
|
117
|
+
|
|
118
|
+
## 5. IPC: HTTP routes
|
|
119
|
+
|
|
120
|
+
HTTP carries every request/response operation: start, stop, list, restart, log management, shutdown. The transport is plain `http` on `127.0.0.1` with an OS-assigned port. Loopback isolation is the security boundary; there is no auth on these routes, so binding anywhere other than `127.0.0.1` is unsafe.
|
|
121
|
+
|
|
122
|
+
Server: `src/daemon/ipcServer.ts`. Client: `src/daemon/ipcClient.ts`. Public facade: `src/daemon/tunnelClient.ts`.
|
|
123
|
+
|
|
124
|
+
| Method | Path | Purpose |
|
|
125
|
+
| ------ | ----------------------------------------------------------- | ----------------------------------------------- |
|
|
126
|
+
| GET | `/ping` | Health + uptime |
|
|
127
|
+
| GET | `/tunnels` | List V2 |
|
|
128
|
+
| GET | `/tunnels/:id` | Get one |
|
|
129
|
+
| POST | `/tunnels/start` | Start by saved-config name |
|
|
130
|
+
| POST | `/tunnels/start-config` | Start with inline V2 config |
|
|
131
|
+
| POST | `/tunnels/stop` | `{tunnelid}` |
|
|
132
|
+
| POST | `/tunnels/restart` | `{tunnelid}` |
|
|
133
|
+
| GET | `/tunnels-v1` | List V1 (compat) |
|
|
134
|
+
| POST | `/tunnels/start-v1` | V1 start (used by remote management) |
|
|
135
|
+
| POST | `/tunnels/update-config` | V1 update |
|
|
136
|
+
| POST | `/tunnels/update-config-v2` | V2 update |
|
|
137
|
+
| POST | `/tunnels/remove-stopped` | Remove a stopped tunnel by tunnelid or configId |
|
|
138
|
+
| GET | `/loglevel`, POST `/loglevel` | Daemon-wide log level |
|
|
139
|
+
| GET | `/config/tunnel-logging`, POST `/config/tunnel-logging` | Tunnel logging toggle |
|
|
140
|
+
| GET | `/logs/paths` | All tunnel log file paths + running flags |
|
|
141
|
+
| GET | `/logs/resolve?q=` | Resolve a name/id/configId to a log path |
|
|
142
|
+
| POST | `/shutdown` | Graceful shutdown |
|
|
143
|
+
|
|
144
|
+
Conventions:
|
|
145
|
+
|
|
146
|
+
- Every request sets `X-Pinggy-Origin: app|cli|remote` (defaulted in `parseOrigin()` to `cli`). Stored on the tunnel and used in log filenames.
|
|
147
|
+
- V2 is the canonical shape. V1 routes (`/tunnels-v1`, `/tunnels/start-v1`, `/tunnels/update-config`) exist for remote management payloads that still use the older schema. New code should target V2.
|
|
148
|
+
- `start` is idempotent on `configId`. If a tunnel with the same `configId` is already running, the daemon returns `ErrorResponse{code: TunnelAlreadyRunningError}` and the CLI prints the existing state instead of starting a duplicate.
|
|
149
|
+
- A successful operation returns its typed response with HTTP 200. An expected application-level failure returns `ErrorResponse` JSON, still with HTTP 200; the route handled the request, the operation just failed. Non-200 means a transport-level problem (daemon died mid-request, port in use after spawn, etc.) and surfaces as a thrown error in `IPCClient`.
|
|
150
|
+
- The `mode` field (added to `start`, `start-config`, `start-v1`) tells `trackIPCTunnelStart` whether to persist the tunnel to `daemon-state.json`. Foreground tunnels are not persisted; detached tunnels are. The default is `"detached"`, which is what remote management and `DaemonTunnelHandler` rely on.
|
|
151
|
+
|
|
152
|
+
## 6. IPC: WebSocket event stream
|
|
153
|
+
|
|
154
|
+
Path: `ws://127.0.0.1:<port>/ws`. Schema in `src/daemon/wsProtocol.ts`.
|
|
155
|
+
|
|
156
|
+
Client → Daemon:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
{ type: "subscribe", tunnelId: "...", mode: "foreground" | "detached" }
|
|
160
|
+
{ type: "unsubscribe", tunnelId: "..." }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Daemon → Client (always wrapped in a `tunnel_event`):
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
{
|
|
167
|
+
type: "tunnel_event",
|
|
168
|
+
tunnelId: "...",
|
|
169
|
+
event: "url_ready" | "stats" | "disconnect" | "reconnecting" | "reconnected"
|
|
170
|
+
| "reconnection_failed" | "error" | "stopped" | "will_reconnect"
|
|
171
|
+
| "worker_error" | "subscribed" | "error_response",
|
|
172
|
+
payload: { ... }
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
On `subscribe`, the daemon registers a fan-out of listeners against `TunnelManager` for the tunnel and remembers their listener IDs on the session. On `unsubscribe` or session close, every listener is deregistered. See `handleSubscribe`, `deregisterListeners`, `cleanupSession` in `ipcServer.ts`.
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
Subscribe sequence:
|
|
180
|
+
|
|
181
|
+
CLI Daemon TunnelManager
|
|
182
|
+
│ ws.send(subscribe) │ │
|
|
183
|
+
├──────────────────────────────▶ │
|
|
184
|
+
│ │ registerStatsListener │
|
|
185
|
+
│ ├───────────────────────────────▶│
|
|
186
|
+
│ │ registerDisconnectListener │
|
|
187
|
+
│ ├───────────────────────────────▶│
|
|
188
|
+
│ │ (… 7 more listeners) │
|
|
189
|
+
│ │ │
|
|
190
|
+
│ │ commit subscription │
|
|
191
|
+
│ │ sessionTracker.attach(tid, sid, mode)
|
|
192
|
+
│ tunnel_event "subscribed" │ │
|
|
193
|
+
◀──────────────────────────────┤ │
|
|
194
|
+
│ │ stats fired ◀────────────────│
|
|
195
|
+
│ tunnel_event "stats" │ │
|
|
196
|
+
◀──────────────────────────────┤ │
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The CLI side dispatches each `tunnel_event` to user-registered callbacks via `TunnelClient.handleWsMessage`. Callbacks are stored per event type in arrays (`callbacks.stats`, `callbacks.disconnect`, etc.)
|
|
200
|
+
|
|
201
|
+
## 7. Foreground vs detached: SessionTracker
|
|
202
|
+
|
|
203
|
+
`SessionTracker` in `src/daemon/sessionTracker.ts` tracks ownership of each running tunnel.
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
ownership: Map<tunnelId, { sessionId, mode: "foreground" | "detached" }>
|
|
207
|
+
graceTimers: Map<tunnelId, NodeJS.Timeout>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
State machine for a tunnel:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
subscribe(mode="foreground")
|
|
214
|
+
┌──────────────────────────────────────────────┐
|
|
215
|
+
│ ▼
|
|
216
|
+
(no owner) FOREGROUND
|
|
217
|
+
│ ▲ │
|
|
218
|
+
│ │ │ WS session disconnects
|
|
219
|
+
│ │ ▼
|
|
220
|
+
│ │ GRACE (5000ms)
|
|
221
|
+
│ │ subscribe(mode="foreground") │
|
|
222
|
+
│ │ (another CLI re-attaches) │
|
|
223
|
+
│ │ ◀────────────────────────────────────────┤
|
|
224
|
+
│ │ │ timer expires
|
|
225
|
+
│ │ ▼
|
|
226
|
+
│ └─────── manager.stopTunnel(tunnelId) ◀────┘
|
|
227
|
+
│
|
|
228
|
+
│ subscribe(mode="detached")
|
|
229
|
+
│ or remote-management start
|
|
230
|
+
▼
|
|
231
|
+
DETACHED (session disconnect has no effect)
|
|
232
|
+
│
|
|
233
|
+
│ /tunnels/stop or daemon shutdown
|
|
234
|
+
▼
|
|
235
|
+
(removed)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Key invariants:
|
|
239
|
+
|
|
240
|
+
- Foreground tunnels die 5 seconds after their WS session disconnects if nobody re-attaches. Implemented as `startGraceTimer` → `killOrphanedTunnel` in `SessionTracker`.
|
|
241
|
+
- Detached tunnels are immune to session disconnect and persist in `daemon-state.json` for crash recovery.
|
|
242
|
+
- `attach(tunnelId, sessionId, mode)` cancels any pending grace timer for that tunnel.
|
|
243
|
+
- `pinggy attach <name|id>` opens a fresh foreground subscription; if the tunnel was in detached mode, the call still succeeds, the timer is not armed (mode stays foreground while subscribed).
|
|
244
|
+
|
|
245
|
+
## 8. Crash recovery
|
|
246
|
+
|
|
247
|
+
`src/daemon/stateStore.ts` writes `daemon-state.json` after every tunnel start/stop. The schema:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
interface DaemonState {
|
|
251
|
+
tunnels: DaemonStateTunnel[]; // {tunnelId, configId, name, origin, config, mode, startedAt}
|
|
252
|
+
lastUpdated: string;
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Two lifecycle paths:
|
|
257
|
+
|
|
258
|
+
- **Clean shutdown** (`/shutdown` or signal): `clearDaemonState()` deletes the file. Next daemon start sees nothing to recover.
|
|
259
|
+
- **Crash** (SIGKILL, OOM, panic): the file remains. Next daemon start calls `restoreCrashedTunnels()`, which restarts each `mode === "detached"` tunnel using the persisted config and stable `tunnelId` (so log files stay continuous). Foreground tunnels are not restored: there is no CLI to attach.
|
|
260
|
+
|
|
261
|
+
Auto-start tunnels are independent of crash recovery. They come from saved configs marked `autoStart: true` (`getAutoStartConfigs()`), not from the state file.
|
|
262
|
+
|
|
263
|
+
## 9. Tunnel lifecycle end-to-end
|
|
264
|
+
|
|
265
|
+
Foreground start (`pinggy start <name>`):
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
CLI: pinggy start my-tunnel
|
|
269
|
+
│
|
|
270
|
+
▼
|
|
271
|
+
subcommands.handleStart()
|
|
272
|
+
├─ resolveConfig("my-tunnel") → SavedTunnelConfig
|
|
273
|
+
├─ buildFinalConfig() with optional flag overrides
|
|
274
|
+
└─ startForegroundViaDaemon(finalConfig)
|
|
275
|
+
│
|
|
276
|
+
▼
|
|
277
|
+
initClient() → new TunnelClient() → ensureDaemon()
|
|
278
|
+
│ (spawns daemon if missing)
|
|
279
|
+
▼
|
|
280
|
+
client.handleStartV2(finalConfig) ── HTTP POST /tunnels/start-config
|
|
281
|
+
│ │
|
|
282
|
+
│ ▼
|
|
283
|
+
│ IPCServer.handleStartV2
|
|
284
|
+
│ │
|
|
285
|
+
│ ▼
|
|
286
|
+
│ TunnelManager.createTunnel + startTunnel
|
|
287
|
+
│ │
|
|
288
|
+
│ ▼
|
|
289
|
+
│ attachTunnelLogger(tid, origin, name)
|
|
290
|
+
│ │
|
|
291
|
+
│ ▼
|
|
292
|
+
│ SDK opens tunnel, returns urls
|
|
293
|
+
│ TunnelResponseV2 ◀───────────────────── 200 OK
|
|
294
|
+
▼
|
|
295
|
+
client.attach(tunnelId, "foreground") ── WS subscribe
|
|
296
|
+
│
|
|
297
|
+
▼
|
|
298
|
+
connectTui()
|
|
299
|
+
├─ TTY available + !noTui → blessed TUI
|
|
300
|
+
└─ else → plain stdout
|
|
301
|
+
│
|
|
302
|
+
▼
|
|
303
|
+
await TUI exit / SIGINT
|
|
304
|
+
│
|
|
305
|
+
▼
|
|
306
|
+
client.handleStop(tunnelId) ── HTTP POST /tunnels/stop
|
|
307
|
+
client.close() ── WS close
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Background start adds `-b` flag → `startBackgroundTunnels()`. The CLI exits immediately after the daemon confirms start; the tunnel keeps running.
|
|
311
|
+
|
|
312
|
+
Attach (`pinggy attach <name|id>`):
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
attachCommand.handleAttach()
|
|
316
|
+
├─ ensureDaemon()
|
|
317
|
+
├─ client.handleListV2()
|
|
318
|
+
├─ findTunnel(list, nameOrId) (id exact, id prefix, name, configId)
|
|
319
|
+
├─ client.attach(tunnelId, "foreground") (cancels detached grace timer? no, only foreground grace)
|
|
320
|
+
└─ connectTui()
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The first time a detached tunnel is attached, the SessionTracker entry flips to `foreground` while the WS session is open. When the TUI quits, the user can choose to stop the tunnel (default in `connectTui`'s `onExit`).
|
|
324
|
+
|
|
325
|
+
## 10. Components by directory
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
src/
|
|
329
|
+
├── main.ts Entry dispatch (daemon-child / subcommand / legacy)
|
|
330
|
+
├── index.ts Library exports for embedding in Electron
|
|
331
|
+
├── types.ts Shared types: TunnelStatus, FinalConfig, ErrorResponse, enums
|
|
332
|
+
├── logger.ts, logger/ Winston root + per-tunnel transport attach/detach
|
|
333
|
+
│
|
|
334
|
+
├── cli/ Foreground CLI surface
|
|
335
|
+
│ ├── subcommands.ts Router for `config|start|stop|ps|attach|daemon|logs|log|restart`
|
|
336
|
+
│ ├── startCli.ts Foreground/background/multi/auto-start orchestration
|
|
337
|
+
│ ├── buildAndStartTunnel.ts Legacy single-tunnel path
|
|
338
|
+
│ ├── buildConfig.ts CLI flags + positionals → FinalConfig
|
|
339
|
+
│ ├── configStore.ts Persisted saved-config CRUD
|
|
340
|
+
│ ├── daemonCommands.ts `pinggy daemon start|stop|status|install-service|...`
|
|
341
|
+
│ ├── attachCommand.ts `pinggy attach`
|
|
342
|
+
│ ├── psCommand.ts `pinggy ps`
|
|
343
|
+
│ ├── stopCommand.ts `pinggy stop`
|
|
344
|
+
│ ├── restartCommand.ts `pinggy restart`
|
|
345
|
+
│ ├── logCommand.ts, logsCommand.ts `pinggy log`, `pinggy logs`
|
|
346
|
+
│ ├── options.ts, extendedOptions.ts, defaults.ts, help.ts
|
|
347
|
+
│
|
|
348
|
+
├── daemon/ Daemon process + IPC
|
|
349
|
+
│ ├── daemonChild.ts runDaemonChild() entry. Persistence, recovery, auto-start.
|
|
350
|
+
│ ├── daemonManager.ts getDaemonInfo, startDaemon, stopDaemon (spawn/discovery)
|
|
351
|
+
│ ├── ipcServer.ts HTTP + WS endpoints. Owns Routes map + WS sessions.
|
|
352
|
+
│ ├── ipcClient.ts Raw HTTP wrapper (no WS). Internal to tunnelClient.ts only.
|
|
353
|
+
│ ├── tunnelClient.ts PUBLIC facade. TunnelClient + DaemonTunnelHandler.
|
|
354
|
+
│ ├── wsProtocol.ts ClientMessage, TunnelEvent, TunnelEventPayloadMap.
|
|
355
|
+
│ ├── sessionTracker.ts Foreground/detached ownership + grace timer.
|
|
356
|
+
│ ├── stateStore.ts daemon-state.json read/write/clear.
|
|
357
|
+
│ └── serviceInstaller.ts systemd / launchd / Task Scheduler installers.
|
|
358
|
+
│
|
|
359
|
+
├── tunnel_manager/
|
|
360
|
+
│ └── TunnelManager.ts Singleton wrapping @pinggy/pinggy SDK. Listener fanout.
|
|
361
|
+
│
|
|
362
|
+
├── remote_management/ Remote control via Pinggy management WS
|
|
363
|
+
│ ├── remoteManagement.ts Connect/disconnect + state machine
|
|
364
|
+
│ ├── handler.ts TunnelHandler interface + TunnelOperations impl
|
|
365
|
+
│ ├── remote_schema.ts Zod schemas for V1 + V2 payloads
|
|
366
|
+
│ ├── websocket_handlers.ts, websocket_printer.ts
|
|
367
|
+
│
|
|
368
|
+
├── tui/ Foreground UI
|
|
369
|
+
│ ├── blessed/ blessed-based TUI for tunnels
|
|
370
|
+
│ ├── ink/ React/Ink prototypes
|
|
371
|
+
│ └── spinner/ Loading spinner widget
|
|
372
|
+
│
|
|
373
|
+
├── utils/ Cross-cutting helpers
|
|
374
|
+
│ ├── configDir.ts Per-OS config + log paths
|
|
375
|
+
│ ├── parseArgs.ts CLI arg parser
|
|
376
|
+
│ ├── printer.ts CLIPrinter singleton
|
|
377
|
+
│ ├── getFreePort.ts Free local port for web debugger
|
|
378
|
+
│ ├── FileServer.ts Static file serving
|
|
379
|
+
│ └── htmlTemplates.ts Built-in error pages
|
|
380
|
+
│
|
|
381
|
+
└── workers/
|
|
382
|
+
└── file_serve_worker.ts Worker thread for --serve
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## 11. Key types
|
|
386
|
+
|
|
387
|
+
- `TunnelOrigin = "app" | "cli" | "remote"` (`src/tunnel_manager/TunnelManager.ts`). Set on every tunnel at creation; flows into log filenames.
|
|
388
|
+
- `ClientOrigin = "app" | "cli" | "remote"` (`src/daemon/ipcClient.ts`). Same values, used in the `X-Pinggy-Origin` header.
|
|
389
|
+
- `TunnelStateType` (`src/types.ts`): `idle | starting | running | live | closed | exited`.
|
|
390
|
+
- `FinalConfig` (`src/types.ts`): the SDK's `TunnelConfigurationV1` plus `conf?` and `saveconf?`. Used by `buildConfig.ts` and threaded through to the daemon.
|
|
391
|
+
- `TunnelResponseV2` (`src/remote_management/handler.ts`): `{tunnelid, remoteurls, tunnelconfig, status, stats, greetmsg?}`. The canonical wire shape.
|
|
392
|
+
- `DaemonInfo` (`src/daemon/daemonChild.ts`): `{pid, port, startedAt}`. The contents of `daemon.json`.
|
|
393
|
+
- `DaemonStateTunnel` (`src/daemon/stateStore.ts`): `{tunnelId, configId, name, origin, config, mode, startedAt}`. Crash recovery row.
|
|
394
|
+
|
|
395
|
+
## 12. Singletons and listener maps
|
|
396
|
+
|
|
397
|
+
The daemon process holds these singletons:
|
|
398
|
+
|
|
399
|
+
- `TunnelManager.getInstance()`. Wraps the SDK. Holds `Map<tunnelId, ManagedTunnel>`. All tunnel state lives here.
|
|
400
|
+
- `logger` (winston). Global root logger. Per-tunnel `File` transports are added/removed by `attachTunnelLogger` / `detachTunnelLogger` in `src/logger/tunnelLogger.ts`, gated by `isTunnelLoggingEnabled()`.
|
|
401
|
+
- `CLIPrinter`. Only used by CLI process for foreground output. Daemon does not print to stdout.
|
|
402
|
+
|
|
403
|
+
Listener registration pattern (used by TunnelManager and propagated by IPCServer):
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
Map<tunnelId, Map<listenerId, (...) => void>>
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Register returns a `listenerId`; deregister takes it back. The IPCServer remembers `${type}:${listenerId}` strings in `session.listenerIds.get(tunnelId)` so a session can deregister everything cleanly on close.
|
|
410
|
+
|
|
411
|
+
## 13. Origin tagging and log file naming
|
|
412
|
+
|
|
413
|
+
When a tunnel is created, the IPCServer reads the `X-Pinggy-Origin` header and passes it to `TunnelManager.createTunnel(config, origin)`. The origin is stored on `ManagedTunnel.origin` and is used by `getTunnelLogPath(tunnelId, origin, name)`:
|
|
414
|
+
|
|
415
|
+
```
|
|
416
|
+
<origin>__<name>__<tunnelId>.log when name present
|
|
417
|
+
<origin>__<tunnelId>.log when nameless
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Example: `cli__my-api__abc123.log`, `app__1700000000_xyz789.log`, `remote__staging__def456.log`.
|
|
421
|
+
|
|
422
|
+
This is how a shared daemon distinguishes tunnels from different clients on disk. The log viewers in the desktop app filter by the origin prefix.
|
|
423
|
+
|
|
424
|
+
## 14. Logging architecture
|
|
425
|
+
|
|
426
|
+
Three log artifacts:
|
|
427
|
+
|
|
428
|
+
| Log | Writer | Path | Lifetime |
|
|
429
|
+
| --------------- | ------------------------------------- | ----------------------------------------------------- | ----------------------------- |
|
|
430
|
+
| Application log | Electron host via `electron-log` | `<userData>/logs/` | Persistent, rolled |
|
|
431
|
+
| Daemon log | winston root inside daemon | `<logDir>/daemon.log` | Persistent, rolled |
|
|
432
|
+
| Tunnel log | winston `File` transport per tunnel | `<logDir>/tunnels/<origin>__<name>__<tunnelId>.log` | While tunnel runs (10MB × 3) |
|
|
433
|
+
|
|
434
|
+
Toggle: `setTunnelLoggingEnabled(false)` detaches every per-tunnel transport immediately. Tunnels keep running; nothing else changes. Re-enabling does not retroactively re-attach to running tunnels (transports are recreated on next tunnel start).
|
|
435
|
+
|
|
436
|
+
CLI logs (the short-lived foreground process) go to stdout by default. The daemon log captures everything inside the long-running process.
|
|
437
|
+
|
|
438
|
+
## 15. Remote management integration
|
|
439
|
+
|
|
440
|
+
`src/remote_management/remoteManagement.ts` opens a WebSocket to Pinggy's management endpoint and forwards tunnel commands. The receiver implements `TunnelHandler`. Two implementations exist:
|
|
441
|
+
|
|
442
|
+
- `TunnelOperations` (`src/remote_management/handler.ts`). Direct, in-process. Used when remote management runs **inside the daemon** (not the default in CLI).
|
|
443
|
+
- `DaemonTunnelHandler` (`src/daemon/tunnelClient.ts`). Proxy. Forwards every `handleStart`, `handleStop`, etc. over HTTP to the daemon. Used when remote management runs **inside the CLI process** (the current default).
|
|
444
|
+
|
|
445
|
+
`TunnelClient.forRemoteManagement()` returns a `DaemonTunnelHandler` with `origin = "remote"`, so tunnels created via remote management are tagged distinctly and logged separately.
|
|
446
|
+
|
|
447
|
+
Same code path as user-typed subcommands: the daemon does not know or care that the request came from remote management.
|
|
448
|
+
|
|
449
|
+
## 16. Service installation
|
|
450
|
+
|
|
451
|
+
`pinggy daemon install-service` writes a platform-specific service file and enables it:
|
|
452
|
+
|
|
453
|
+
- Linux: systemd user unit at `~/.config/systemd/user/pinggy.service`. `Type=simple`, runs `<binary> --_daemon-child` directly (no fork). `systemctl --user enable --now`.
|
|
454
|
+
- macOS: launchd `LaunchAgent` at `~/Library/LaunchAgents/io.pinggy.agent.plist`. `launchctl load`.
|
|
455
|
+
- Windows: Task Scheduler entry.
|
|
456
|
+
|
|
457
|
+
The installer resolves the binary via `resolveBinary()` in `src/daemon/serviceInstaller.ts`. Prefers `process.execPath` when running as a pkg binary, falls back to `which pinggy`, then to `node <script>`.
|
|
458
|
+
|
|
459
|
+
A system service gives restart-on-failure and start-at-login. The plain daemon mode does not.
|
|
460
|
+
|
|
461
|
+
## 17. Reference for AI agents
|
|
462
|
+
|
|
463
|
+
When making changes, these are the load-bearing files and the contracts that matter.
|
|
464
|
+
|
|
465
|
+
**Entry points**
|
|
466
|
+
|
|
467
|
+
- `src/main.ts` — argv dispatch. Adding a new top-level subcommand means updating `SUBCOMMANDS` in `src/cli/subcommands.ts` AND adding a handler. Updating the dispatch in `main.ts` is rarely needed.
|
|
468
|
+
- `src/daemon/daemonChild.ts` — daemon process entry. Anything that needs to run **once** when the daemon boots goes here.
|
|
469
|
+
|
|
470
|
+
**Adding a new IPC route**
|
|
471
|
+
|
|
472
|
+
1. Add the route key + handler in `IPCServer.registerRoutes()` (`src/daemon/ipcServer.ts`).
|
|
473
|
+
2. Add the wire method to `IPCClient` (`src/daemon/ipcClient.ts`).
|
|
474
|
+
3. Expose it on `TunnelClient` (`src/daemon/tunnelClient.ts`) — never let callers reach `IPCClient` directly.
|
|
475
|
+
4. If it streams events, add the event type to `DaemonEventType` and payload to `TunnelEventPayloadMap` in `src/daemon/wsProtocol.ts`, register the listener in `handleSubscribe`, deregister in `deregisterListeners`, dispatch in `TunnelClient.handleWsMessage`.
|
|
476
|
+
|
|
477
|
+
**Adding a new tunnel state**
|
|
478
|
+
|
|
479
|
+
If new state needs to survive a daemon crash, extend `DaemonStateTunnel` in `src/daemon/stateStore.ts` and update `restoreCrashedTunnels()` in `daemonChild.ts`. If it is only needed in memory, put it on `ManagedTunnel` in `src/tunnel_manager/TunnelManager.ts`.
|
|
480
|
+
|
|
481
|
+
**Foreground vs detached**
|
|
482
|
+
|
|
483
|
+
If you spawn a tunnel and want it to die when the CLI quits, subscribe with `mode: "foreground"`. If it should outlive the CLI, use `mode: "detached"` (and the daemon will write it to `daemon-state.json` for crash recovery).
|
|
484
|
+
|
|
485
|
+
**Origin**
|
|
486
|
+
|
|
487
|
+
Every tunnel must have a correct origin. The default in `parseOrigin()` is `cli`. If you add a new client surface, plumb a new origin value through `TunnelOrigin` and `ClientOrigin`. Origin affects log filenames; renaming an origin is a breaking change for log viewers.
|
|
488
|
+
|
|
489
|
+
**Singletons in tests**
|
|
490
|
+
|
|
491
|
+
`TunnelManager.instance = undefined` between tests, plus `jest.clearAllMocks()`. See `src/_tests_/` for examples. Use `@jest/globals` imports — there is no global `jest`.
|
|
492
|
+
|
|
493
|
+
**English style**
|
|
494
|
+
|
|
495
|
+
No em-dashes. Short phrases. Present tense. See `CLAUDE.md` § "English Style" before writing user-facing strings, error messages, or docs.
|
|
496
|
+
|
|
497
|
+
**Gotchas**
|
|
498
|
+
|
|
499
|
+
- Do not log to stdout from the daemon. The daemon's stdio is closed by `spawn({stdio: "ignore"})` on most platforms; writes will silently fail or fill an OS buffer.
|
|
500
|
+
- Do not call `process.exit()` inside daemon route handlers. The shutdown route is the only path that should terminate the process, and it does so via `setTimeout(() => process.exit(0), 200)` so the HTTP response can flush first.
|
|
501
|
+
- Do not import from `src/daemon/ipcClient.ts` outside `src/daemon/`. Use `TunnelClient` from `tunnelClient.ts`.
|
|
502
|
+
- The first CLI run on a fresh install will spawn the daemon and pay an 8-second worst-case latency. Subsequent runs reuse the daemon.
|
|
503
|
+
- `daemon.json` writes are atomic (write tmp, rename). Direct partial reads should not happen, but `getDaemonInfo()` still tolerates malformed JSON by returning `null` and triggering a respawn.
|
|
504
|
+
- The IPC server only listens on `127.0.0.1`. Do not change to `0.0.0.0` without adding authentication. The current design assumes loopback isolation.
|
|
505
|
+
|
|
506
|
+
## 18. Quick diagrams
|
|
507
|
+
|
|
508
|
+
### Start sequence (foreground, daemon already running)
|
|
509
|
+
|
|
510
|
+
```
|
|
511
|
+
user CLI Daemon SDK / TunnelManager
|
|
512
|
+
│ pinggy start api │ │
|
|
513
|
+
├──────────────────────▶ │
|
|
514
|
+
│ │ GET /ping (sanity) │
|
|
515
|
+
│ ├───────────────▶ │
|
|
516
|
+
│ │ ◀───────────── │ {status:"ok"} │
|
|
517
|
+
│ │ POST /tunnels/start-config │
|
|
518
|
+
│ ├───────────────▶│ createTunnel + start │
|
|
519
|
+
│ │ ├──────────────────────▶
|
|
520
|
+
│ │ │ urls │
|
|
521
|
+
│ │ ◀──────────────────────┤
|
|
522
|
+
│ │ 200 TunnelResponseV2 │
|
|
523
|
+
│ │ ◀──────────────│ │
|
|
524
|
+
│ │ ws connect → /ws │
|
|
525
|
+
│ │ ws subscribe(tid, "foreground") │
|
|
526
|
+
│ ├───────────────▶│ register N listeners │
|
|
527
|
+
│ │ tunnel_event "subscribed" │
|
|
528
|
+
│ │ ◀──────────────│ │
|
|
529
|
+
│ TUI render │ │ │
|
|
530
|
+
│ ◀────────────────────│ │ │
|
|
531
|
+
│ │ tunnel_event "stats" (repeated) │
|
|
532
|
+
│ │ ◀──────────────│ │
|
|
533
|
+
│ Ctrl+C │ │
|
|
534
|
+
├──────────────────────▶ POST /tunnels/stop │
|
|
535
|
+
│ ├───────────────▶│ stopTunnel │
|
|
536
|
+
│ │ ◀──────────────│ │
|
|
537
|
+
│ │ ws close │
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
### Crash and recovery
|
|
541
|
+
|
|
542
|
+
```
|
|
543
|
+
┌──────────────────────────────────┐
|
|
544
|
+
│ Daemon running, 2 detached │
|
|
545
|
+
│ daemon-state.json: [t1, t2] │
|
|
546
|
+
└──────────────────────────────────┘
|
|
547
|
+
│ SIGKILL / OOM
|
|
548
|
+
▼
|
|
549
|
+
┌──────────────────────────────────┐
|
|
550
|
+
│ Process gone, but state file │
|
|
551
|
+
│ still on disk │
|
|
552
|
+
└──────────────────────────────────┘
|
|
553
|
+
│ next pinggy command
|
|
554
|
+
▼
|
|
555
|
+
getDaemonInfo() returns null (PID dead) ──▶ unlinks daemon.json
|
|
556
|
+
│
|
|
557
|
+
▼
|
|
558
|
+
startDaemon() spawns new child
|
|
559
|
+
│
|
|
560
|
+
▼
|
|
561
|
+
runDaemonChild()
|
|
562
|
+
├─ writeDaemonInfo()
|
|
563
|
+
├─ restoreCrashedTunnels() ──▶ TunnelManager.createTunnel + startTunnel for t1, t2
|
|
564
|
+
│ using same tunnelid (log continuity)
|
|
565
|
+
├─ getAutoStartConfigs() ──▶ starts auto-start tunnels
|
|
566
|
+
└─ "Daemon ready"
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
### Two clients, one daemon
|
|
570
|
+
|
|
571
|
+
```
|
|
572
|
+
Time
|
|
573
|
+
│
|
|
574
|
+
│ 10:00 Electron app launches.
|
|
575
|
+
│ App finds no daemon → spawns. Daemon starts.
|
|
576
|
+
│ App opens tunnel "web" with origin=app.
|
|
577
|
+
│ log: app__web__t1.log
|
|
578
|
+
│
|
|
579
|
+
│ 10:05 User runs `pinggy ps` from terminal.
|
|
580
|
+
│ CLI finds daemon.json → connects (no spawn).
|
|
581
|
+
│ Both tunnels (including the App's) appear in ps.
|
|
582
|
+
│
|
|
583
|
+
│ 10:06 User runs `pinggy start api`.
|
|
584
|
+
│ Origin=cli. Foreground TUI opens.
|
|
585
|
+
│ log: cli__api__t2.log
|
|
586
|
+
│
|
|
587
|
+
│ 10:10 User Ctrl+C's the CLI tunnel.
|
|
588
|
+
│ CLI sends /tunnels/stop. Daemon stops t2.
|
|
589
|
+
│ Daemon keeps running. App's tunnel t1 unaffected.
|
|
590
|
+
│
|
|
591
|
+
│ 10:30 User closes Electron app.
|
|
592
|
+
│ App calls /shutdown? No — App leaves the daemon up
|
|
593
|
+
│ since CLI/remote may still use it.
|
|
594
|
+
│ Daemon keeps running with t1 still live.
|
|
595
|
+
▼
|
|
596
|
+
```
|
package/README.md
CHANGED
|
@@ -324,7 +324,7 @@ Extended options provide advanced controls. Specify them as positional values li
|
|
|
324
324
|
- x:reverseproxy Disable built-in reverse-proxy header injection.
|
|
325
325
|
- x:xff Add X-Forwarded-For.
|
|
326
326
|
- x:fullurl | x:fullrequesturl Include original request URL.
|
|
327
|
-
- x:haproxy | x:haproxy:v1 | x:haproxy:v2 Send HAProxy PROXY protocol header to the local server (default v1).
|
|
327
|
+
- x:haproxy | x:haproxy:v1 | x:haproxy:v2 Send HAProxy PROXY protocol header to the local server (default v1). TCP tunnels only.
|
|
328
328
|
- w:<cidr>[,<cidr>...] Whitelist IPs (IPv4 CIDR).
|
|
329
329
|
- k:<token> Set Bearer token(s) for auth (repeatable).
|
|
330
330
|
- b:<user:pass> Add Basic Auth credentials (repeatable).
|
|
@@ -339,8 +339,8 @@ Examples:
|
|
|
339
339
|
- Allow only a local subnet:
|
|
340
340
|
pinggy w:192.168.1.0/24 -l 8080
|
|
341
341
|
|
|
342
|
-
- Send the real client IP to a PROXY-protocol-aware backend:
|
|
343
|
-
pinggy x:haproxy:v2 -l 8080
|
|
342
|
+
- Send the real client IP to a PROXY-protocol-aware backend (TCP tunnels only):
|
|
343
|
+
pinggy --type tcp x:haproxy:v2 -l 8080
|
|
344
344
|
|
|
345
345
|
To generate advanced CLI arguments, use [Configure from Pinggy.io](https://pinggy.io/)
|
|
346
346
|
|
|
@@ -256,6 +256,19 @@ var TunnelConfigV1Schema = z.object({
|
|
|
256
256
|
haProxy: z.enum([HaProxyVersion.V1, HaProxyVersion.V2]).optional(),
|
|
257
257
|
serve: z.string().optional(),
|
|
258
258
|
optional: z.record(z.string(), z.unknown()).optional()
|
|
259
|
+
}).superRefine((config, ctx) => {
|
|
260
|
+
if (!config.haProxy) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const entries = Array.isArray(config.forwarding) ? config.forwarding : [];
|
|
264
|
+
const primaryType = entries.length > 0 ? entries[0].type : void 0;
|
|
265
|
+
if (primaryType !== TunnelType.Tcp) {
|
|
266
|
+
ctx.addIssue({
|
|
267
|
+
code: "custom",
|
|
268
|
+
path: ["haProxy"],
|
|
269
|
+
message: `haProxy can only be used with "tcp" tunnels, but the first forwarding rule has type: ${primaryType ?? TunnelType.Http}`
|
|
270
|
+
});
|
|
271
|
+
}
|
|
259
272
|
});
|
|
260
273
|
var TUNNEL_CONFIG_V1_KEYS = Object.keys(
|
|
261
274
|
TunnelConfigV1Schema.shape
|
|
@@ -1647,7 +1660,7 @@ async function ensureDaemonRunning() {
|
|
|
1647
1660
|
const existing = getDaemonInfo();
|
|
1648
1661
|
if (existing) return existing;
|
|
1649
1662
|
if (process.versions.electron) {
|
|
1650
|
-
const { runDaemonChild } = await import("./daemonChild-
|
|
1663
|
+
const { runDaemonChild } = await import("./daemonChild-3ONQO2XI.js");
|
|
1651
1664
|
const handle = await runDaemonChild({
|
|
1652
1665
|
installSignalHandlers: false,
|
|
1653
1666
|
exitOnFailure: false,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
startDaemon,
|
|
11
11
|
startRemoteManagement,
|
|
12
12
|
stopDaemon
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-HN4YLM7U.js";
|
|
14
14
|
import {
|
|
15
15
|
readDaemonConfig
|
|
16
16
|
} from "./chunk-MT44NAXX.js";
|
|
@@ -137,7 +137,7 @@ function printHelpMessage() {
|
|
|
137
137
|
console.log(" x:noreverseproxy Disable built-in reverse-proxy header injection");
|
|
138
138
|
console.log(" x:localservertls:host Connect to local HTTPS server with SNI");
|
|
139
139
|
console.log(" x:passpreflight Pass CORS preflight requests unchanged");
|
|
140
|
-
console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol (default v1)");
|
|
140
|
+
console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol, TCP tunnels only (default v1)");
|
|
141
141
|
console.log(" a:Key:Val Add header");
|
|
142
142
|
console.log(" u:Key:Val Update header");
|
|
143
143
|
console.log(" r:Key Remove header");
|
|
@@ -840,6 +840,20 @@ function hasRemoteManagement(values) {
|
|
|
840
840
|
const token = values["remote-management"];
|
|
841
841
|
return typeof token === "string" && token.trim().length > 0;
|
|
842
842
|
}
|
|
843
|
+
function validateHaProxy(finalConfig) {
|
|
844
|
+
if (!finalConfig.haProxy) {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
const forwarding = finalConfig.forwarding;
|
|
848
|
+
const entries = Array.isArray(forwarding) ? forwarding : [];
|
|
849
|
+
const primaryType = entries.length > 0 ? entries[0].type : void 0;
|
|
850
|
+
if (primaryType === TunnelType.Tcp) {
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
return new Error(
|
|
854
|
+
`HAProxy PROXY protocol (x:haproxy) can only be used with TCP tunnels, but this tunnel is "${primaryType ?? TunnelType.Http}". Make it a TCP tunnel (--type tcp, or the tcp@ server prefix) or drop x:haproxy.`
|
|
855
|
+
);
|
|
856
|
+
}
|
|
843
857
|
function buildFinalConfig(values, positionals, baseConfig) {
|
|
844
858
|
let token;
|
|
845
859
|
let server;
|
|
@@ -902,6 +916,10 @@ function buildFinalConfig(values, positionals, baseConfig) {
|
|
|
902
916
|
}
|
|
903
917
|
normalizeForwardingToArray(finalConfig, type);
|
|
904
918
|
parseArgs2(finalConfig, remainingPositionals);
|
|
919
|
+
const haProxyErr = validateHaProxy(finalConfig);
|
|
920
|
+
if (haProxyErr instanceof Error) {
|
|
921
|
+
throw haProxyErr;
|
|
922
|
+
}
|
|
905
923
|
storeJson(finalConfig, saveconf);
|
|
906
924
|
return finalConfig;
|
|
907
925
|
}
|
|
@@ -3426,7 +3444,7 @@ async function main() {
|
|
|
3426
3444
|
const { values, positionals, hasAnyArgs } = parseCliArgs(cliOptions);
|
|
3427
3445
|
configureLogger(values);
|
|
3428
3446
|
if (values["_daemon-child"]) {
|
|
3429
|
-
const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-
|
|
3447
|
+
const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-3ONQO2XI.js");
|
|
3430
3448
|
await runDaemonChild2();
|
|
3431
3449
|
return;
|
|
3432
3450
|
}
|
package/dist/index.cjs
CHANGED
|
@@ -2443,6 +2443,19 @@ var init_remote_schema = __esm({
|
|
|
2443
2443
|
haProxy: import_zod.z.enum([import_pinggy3.HaProxyVersion.V1, import_pinggy3.HaProxyVersion.V2]).optional(),
|
|
2444
2444
|
serve: import_zod.z.string().optional(),
|
|
2445
2445
|
optional: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional()
|
|
2446
|
+
}).superRefine((config, ctx) => {
|
|
2447
|
+
if (!config.haProxy) {
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
const entries = Array.isArray(config.forwarding) ? config.forwarding : [];
|
|
2451
|
+
const primaryType = entries.length > 0 ? entries[0].type : void 0;
|
|
2452
|
+
if (primaryType !== import_pinggy3.TunnelType.Tcp) {
|
|
2453
|
+
ctx.addIssue({
|
|
2454
|
+
code: "custom",
|
|
2455
|
+
path: ["haProxy"],
|
|
2456
|
+
message: `haProxy can only be used with "tcp" tunnels, but the first forwarding rule has type: ${primaryType ?? import_pinggy3.TunnelType.Http}`
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2446
2459
|
});
|
|
2447
2460
|
TUNNEL_CONFIG_V1_KEYS = Object.keys(
|
|
2448
2461
|
TunnelConfigV1Schema.shape
|
|
@@ -3039,7 +3052,7 @@ function printHelpMessage() {
|
|
|
3039
3052
|
console.log(" x:noreverseproxy Disable built-in reverse-proxy header injection");
|
|
3040
3053
|
console.log(" x:localservertls:host Connect to local HTTPS server with SNI");
|
|
3041
3054
|
console.log(" x:passpreflight Pass CORS preflight requests unchanged");
|
|
3042
|
-
console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol (default v1)");
|
|
3055
|
+
console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol, TCP tunnels only (default v1)");
|
|
3043
3056
|
console.log(" a:Key:Val Add header");
|
|
3044
3057
|
console.log(" u:Key:Val Update header");
|
|
3045
3058
|
console.log(" r:Key Remove header");
|
|
@@ -4645,6 +4658,20 @@ function hasRemoteManagement(values) {
|
|
|
4645
4658
|
const token = values["remote-management"];
|
|
4646
4659
|
return typeof token === "string" && token.trim().length > 0;
|
|
4647
4660
|
}
|
|
4661
|
+
function validateHaProxy(finalConfig) {
|
|
4662
|
+
if (!finalConfig.haProxy) {
|
|
4663
|
+
return null;
|
|
4664
|
+
}
|
|
4665
|
+
const forwarding = finalConfig.forwarding;
|
|
4666
|
+
const entries = Array.isArray(forwarding) ? forwarding : [];
|
|
4667
|
+
const primaryType = entries.length > 0 ? entries[0].type : void 0;
|
|
4668
|
+
if (primaryType === import_pinggy5.TunnelType.Tcp) {
|
|
4669
|
+
return null;
|
|
4670
|
+
}
|
|
4671
|
+
return new Error(
|
|
4672
|
+
`HAProxy PROXY protocol (x:haproxy) can only be used with TCP tunnels, but this tunnel is "${primaryType ?? import_pinggy5.TunnelType.Http}". Make it a TCP tunnel (--type tcp, or the tcp@ server prefix) or drop x:haproxy.`
|
|
4673
|
+
);
|
|
4674
|
+
}
|
|
4648
4675
|
function buildFinalConfig(values, positionals, baseConfig) {
|
|
4649
4676
|
let token;
|
|
4650
4677
|
let server;
|
|
@@ -4707,6 +4734,10 @@ function buildFinalConfig(values, positionals, baseConfig) {
|
|
|
4707
4734
|
}
|
|
4708
4735
|
normalizeForwardingToArray(finalConfig, type);
|
|
4709
4736
|
parseArgs2(finalConfig, remainingPositionals);
|
|
4737
|
+
const haProxyErr = validateHaProxy(finalConfig);
|
|
4738
|
+
if (haProxyErr instanceof Error) {
|
|
4739
|
+
throw haProxyErr;
|
|
4740
|
+
}
|
|
4710
4741
|
storeJson(finalConfig, saveconf);
|
|
4711
4742
|
return finalConfig;
|
|
4712
4743
|
}
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
initiateRemoteManagement,
|
|
15
15
|
isDaemonRunning,
|
|
16
16
|
startRemoteManagement
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-HN4YLM7U.js";
|
|
18
18
|
import "./chunk-YTJEV27N.js";
|
|
19
19
|
import {
|
|
20
20
|
bulkReplace,
|
|
@@ -139,7 +139,7 @@ async function verifyAndLoad() {
|
|
|
139
139
|
process.exit(1);
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
await import("./main-
|
|
142
|
+
await import("./main-WXFARRMN.js");
|
|
143
143
|
}
|
|
144
144
|
verifyAndLoad().catch((err) => {
|
|
145
145
|
printer_default.fatal(`Failed to start CLI:, ${err}`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
runDaemonChild
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-OH6FSLYF.js";
|
|
5
5
|
import {
|
|
6
6
|
RemoteManagementUnauthorizedError,
|
|
7
7
|
TunnelOperations,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
getRemoteManagementState,
|
|
14
14
|
initiateRemoteManagement,
|
|
15
15
|
isDaemonRunning
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-HN4YLM7U.js";
|
|
17
17
|
import "./chunk-MT44NAXX.js";
|
|
18
18
|
import {
|
|
19
19
|
DaemonHost
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pinggy",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Create secure, shareable tunnels to your localhost and manage them from the command line. ",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
]
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@pinggy/pinggy": "^0.5.
|
|
55
|
+
"@pinggy/pinggy": "^0.5.1",
|
|
56
56
|
"blessed": "^0.1.81",
|
|
57
57
|
"clipboardy": "^5.0.0",
|
|
58
58
|
"mime": "^4.1.0",
|