@lamalibre/create-portlama 1.0.33 → 1.0.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/create-portlama",
3
- "version": "1.0.33",
3
+ "version": "1.0.34",
4
4
  "description": "One-command setup for secure reverse tunnels with a management dashboard",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -74,31 +74,36 @@ portlama ALL=(root) NOPASSWD: /usr/bin/systemctl restart portlama-panel
74
74
  # --- nginx config test ---
75
75
  portlama ALL=(root) NOPASSWD: /usr/sbin/nginx -t
76
76
 
77
- # --- certbot: restrict certonly to --nginx (code always passes --non-interactive) ---
78
- # Note: trailing wildcard allows additional flags; trust boundary is @lamalibre/ scope
79
- portlama ALL=(root) NOPASSWD: /usr/bin/certbot certonly --nginx *
80
- portlama ALL=(root) NOPASSWD: /usr/bin/certbot renew
81
- portlama ALL=(root) NOPASSWD: /usr/bin/certbot renew --cert-name *
82
- portlama ALL=(root) NOPASSWD: /usr/bin/certbot certificates
83
-
84
- # --- openssl: restricted to PKI and Let's Encrypt paths ---
85
- portlama ALL=(root) NOPASSWD: /usr/bin/openssl x509 -in /etc/portlama/pki/* *
86
- portlama ALL=(root) NOPASSWD: /usr/bin/openssl x509 -in /etc/letsencrypt/live/* *
77
+ # --- certbot: restrict to exact flag patterns used by the application ---
78
+ portlama ALL=(root) NOPASSWD: /usr/bin/certbot certonly --nginx -d * --email * --agree-tos --non-interactive
79
+ portlama ALL=(root) NOPASSWD: /usr/bin/certbot renew --non-interactive
80
+ portlama ALL=(root) NOPASSWD: /usr/bin/certbot renew --cert-name * --non-interactive
81
+ portlama ALL=(root) NOPASSWD: /usr/bin/certbot renew --cert-name * --force-renewal --non-interactive
82
+ portlama ALL=(root) NOPASSWD: /usr/bin/certbot certificates --non-interactive
83
+
84
+ # --- openssl: read-only operations (no trailing wildcards) ---
85
+ portlama ALL=(root) NOPASSWD: /usr/bin/openssl x509 -in /etc/portlama/pki/* -serial -noout
86
+ portlama ALL=(root) NOPASSWD: /usr/bin/openssl x509 -in /etc/portlama/pki/* -enddate -noout
87
+ portlama ALL=(root) NOPASSWD: /usr/bin/openssl x509 -checkend 86400 -noout -in /etc/letsencrypt/live/*
88
+ portlama ALL=(root) NOPASSWD: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/*
89
+ portlama ALL=(root) NOPASSWD: /usr/bin/openssl x509 -in /etc/letsencrypt/live/* -enddate -noout
90
+ # --- openssl: PKI generation and signing (trailing * for variable -subj CN) ---
91
+ # Trust boundary: only @lamalibre/ scoped code runs as portlama user
87
92
  portlama ALL=(root) NOPASSWD: /usr/bin/openssl x509 -req -in /etc/portlama/pki/* *
88
93
  portlama ALL=(root) NOPASSWD: /usr/bin/openssl genrsa -out /etc/portlama/pki/* *
89
94
  portlama ALL=(root) NOPASSWD: /usr/bin/openssl req -new -key /etc/portlama/pki/* *
90
95
  portlama ALL=(root) NOPASSWD: /usr/bin/openssl pkcs12 -export -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 -out /etc/portlama/pki/*
91
96
 
92
- # --- mv: restrict source to /tmp/ or known config paths ---
93
- portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/* /var/www/portlama/*
94
- portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/* /etc/nginx/sites-available/*
95
- portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/* /etc/systemd/system/chisel.service
96
- portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/* /etc/systemd/system/authelia.service
97
- portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/* /etc/systemd/system/portlama-panel.service
98
- portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/* /etc/portlama/pki/*
99
- portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/* /usr/local/bin/chisel
100
- portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/* /usr/local/bin/authelia
101
- portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/* /etc/authelia/*
97
+ # --- mv: restrict source to known temp-file prefixes (no bare /tmp/*) ---
98
+ portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/site-index-* /var/www/portlama/*
99
+ portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/site-upload-* /var/www/portlama/*
100
+ portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/invite-page-* /var/www/portlama/*
101
+ portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/nginx-* /etc/nginx/sites-available/*
102
+ portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/chisel-service-* /etc/systemd/system/chisel.service
103
+ portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/authelia-service-* /etc/systemd/system/authelia.service
104
+ portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/chisel-* /usr/local/bin/chisel
105
+ portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/authelia-* /usr/local/bin/authelia
106
+ portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp/portlama-authelia-* /etc/authelia/*
102
107
  portlama ALL=(root) NOPASSWD: /usr/bin/mv /etc/portlama/pki/*.new /etc/portlama/pki/*
103
108
  portlama ALL=(root) NOPASSWD: /usr/bin/mv /etc/nginx/sites-available/*.bak /etc/nginx/sites-available/*
104
109
 
@@ -93,13 +93,13 @@ Source Agent Panel Target Agent
93
93
  │ │ POST /tickets/validate │
94
94
  │ │ {ticketId} │
95
95
  │ │◀───────────────────────────│
96
- │ │ ── timing-safe compare
96
+ │ │ ── HMAC timing-safe compare
97
97
  │ │ ── mark as used │
98
98
  │ │ { valid, transport } │
99
99
  │ │───────────────────────────▶│
100
100
  │ │ │
101
101
  │ │ POST /tickets/sessions │
102
- │ │ {ticketId, sessionId}
102
+ │ │ {ticketId}
103
103
  │ │◀───────────────────────────│
104
104
  │ │ { session } │
105
105
  │ │───────────────────────────▶│
@@ -119,10 +119,16 @@ Source Agent Panel Target Agent
119
119
  2. **Multi-stage validation** — panel checks: source has capability, target has capability, source owns instance, instance is active (not stale/dead), source is not targeting itself, target is assigned to instance
120
120
  3. **Issuance** — 256-bit random ticket ID (`crypto.randomBytes(32)`), 30-second expiry
121
121
  4. **Delivery** — ticket appears in target's inbox (`GET /api/tickets/inbox`)
122
- 5. **Validation** — target calls `POST /api/tickets/validate` with the ticket ID; timing-safe comparison marks it as used atomically
123
- 6. **Session** — target creates a session (`POST /api/tickets/sessions`); panel tracks with heartbeat re-validation
122
+ 5. **Validation** — target calls `POST /api/tickets/validate` with the ticket ID; HMAC-based timing-safe comparison marks it as used atomically
123
+ 6. **Session** — target reports session creation (`POST /api/tickets/sessions`) with only the ticket ID; the panel generates the session ID server-side (`crypto.randomBytes(16)`) and sets all timestamps server-side, then tracks with heartbeat re-validation
124
124
  7. **Cleanup** — tickets expire after 1 hour (removed from store); dead sessions cleaned after 24 hours
125
125
 
126
+ ### Server-Generated Session IDs and Timestamps
127
+
128
+ Session IDs are always generated server-side via `crypto.randomBytes(16).toString('hex')`. The client sends only the `ticketId` when creating a session; the server generates and returns the session ID. This guarantees uniqueness without trusting client input.
129
+
130
+ Similarly, `lastActivityAt` timestamps are always set server-side. The client cannot influence when a session was last active; heartbeats update the timestamp on the server when they arrive, not when the client claims to have sent them.
131
+
126
132
  ### Session Heartbeat Re-validation
127
133
 
128
134
  Every heartbeat checks six conditions. If any fails, the session is terminated:
@@ -144,7 +150,19 @@ Security-sensitive endpoints use generic error responses:
144
150
  - `POST /api/tickets/validate` — returns 401 "Invalid ticket" for all failures (expired, used, wrong target, not found)
145
151
  - `DELETE /api/tickets/instances/:id` — returns 404 for unauthorized deregistration attempts
146
152
 
147
- Ticket validation uses `crypto.timingSafeEqual` to prevent timing attacks.
153
+ Ticket validation uses HMAC-based timing-safe comparison to prevent timing attacks. Both the submitted and stored ticket IDs are HMAC-SHA256'd with a per-process random key before being compared with `crypto.timingSafeEqual`. The HMAC step produces fixed-length digests, eliminating the length-leak that raw `timingSafeEqual` would expose if inputs differed in length, and the per-process key prevents pre-computation attacks if an attacker gains read access to the source code.
154
+
155
+ ### Host Validation (SSRF Prevention)
156
+
157
+ When an instance registers with a `direct` transport strategy, the `host` field is validated against a deny list of private, reserved, and metadata IP ranges. The following are rejected:
158
+
159
+ - Loopback: `localhost`, `127.0.0.1`, `::1`
160
+ - Private IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
161
+ - Link-local: `169.254.0.0/16`
162
+ - Cloud metadata endpoints: `169.254.169.254`, `metadata.google.internal`
163
+ - Zero network: `0.0.0.0/8`
164
+
165
+ This prevents agents from using the ticket system to probe internal services on the panel's network (SSRF).
148
166
 
149
167
  ### Capability Integration
150
168
 
@@ -184,6 +202,7 @@ The integration flow:
184
202
  "port": 9000,
185
203
  "protocol": "wss"
186
204
  },
205
+ "hooks": {}, // Reserved for future hook configuration
187
206
  "installedAt": "2026-03-26T10:00:00.000Z"
188
207
  }
189
208
  ],
@@ -201,7 +220,7 @@ The integration flow:
201
220
  "assignments": [
202
221
  {
203
222
  "agentLabel": "linux-agent",
204
- "instanceScope": "shell:connect:a7f3b2c9d1e2f3a4",
223
+ "instanceScope": "shell:connect:a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
205
224
  "assignedAt": "2026-03-26T10:10:00.000Z",
206
225
  "assignedBy": "admin"
207
226
  }
@@ -217,7 +236,7 @@ The integration flow:
217
236
  {
218
237
  "id": "64-hex-char-ticket-id",
219
238
  "scope": "shell:connect",
220
- "instanceId": "a7f3b2c9d1e2f3a4",
239
+ "instanceId": "a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
221
240
  "source": "macbook-pro",
222
241
  "target": "linux-agent",
223
242
  "createdAt": "2026-03-26T10:15:00.000Z",
@@ -230,10 +249,10 @@ The integration flow:
230
249
  ],
231
250
  "sessions": [
232
251
  {
233
- "sessionId": "session-id",
252
+ "sessionId": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
234
253
  "ticketId": "64-hex-char-ticket-id",
235
254
  "scope": "shell:connect",
236
- "instanceId": "a7f3b2c9d1e2f3a4",
255
+ "instanceId": "a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
237
256
  "source": "macbook-pro",
238
257
  "target": "linux-agent",
239
258
  "createdAt": "2026-03-26T10:15:30.000Z",
@@ -245,6 +264,20 @@ The integration flow:
245
264
  }
246
265
  ```
247
266
 
267
+ ### Client SDK (`@lamalibre/portlama-tickets`)
268
+
269
+ The `@lamalibre/portlama-tickets` package is a TypeScript SDK that provides the client-side ticket lifecycle for plugins and agents. It uses `undici` for HTTP with mTLS dispatcher support and has no other runtime dependencies.
270
+
271
+ The SDK exports three main classes:
272
+
273
+ - **`TicketClient`** — low-level HTTP client for all ticket API endpoints. Handles mTLS authentication, response validation (`assertObject`/`assertField` checks before type assertions), and structured error reporting via `TicketHttpError` (carries the HTTP status code for retriable vs. permanent error distinction).
274
+
275
+ - **`TicketInstanceManager`** (source side) — manages the full instance lifecycle: creates the mTLS dispatcher, registers an instance for a scope, heartbeats it every 60 seconds, requests tickets with a per-agent cooldown (default 120 seconds) to avoid exhausting the global ticket cap, and auto-re-registers on 404 (instance expired). On `stop()`, it deregisters the instance from the panel immediately rather than waiting for the heartbeat timeout.
276
+
277
+ - **`TicketSessionManager`** (target side) — manages the session lifecycle: polls the ticket inbox for matching tickets (filtering by scope, discarding tickets with less than 5 seconds remaining TTL, picking the freshest), validates them, creates sessions, heartbeats every 60 seconds, transitions through `waiting` / `authorized` / `grace` / `terminated` / `stopped` states, and notifies the consuming plugin via an `onStateChange` callback.
278
+
279
+ The dispatcher factory (`createTicketDispatcher`) supports both PEM (cert + key + CA files) and P12 (single .p12 bundle) certificate configurations.
280
+
248
281
  ### Source Files
249
282
 
250
283
  | File | Purpose |
@@ -253,6 +286,10 @@ The integration flow:
253
286
  | `packages/panel-server/src/routes/management/tickets.js` | HTTP route handlers |
254
287
  | `packages/panel-client/src/pages/management/Tickets.jsx` | Admin UI (5-tab interface) |
255
288
  | `packages/portlama-agent/src/lib/panel-api.js` | Agent-side API functions |
289
+ | `packages/portlama-tickets/src/client.ts` | SDK: mTLS HTTP client for ticket API |
290
+ | `packages/portlama-tickets/src/instance-manager.ts` | SDK: source-side instance lifecycle |
291
+ | `packages/portlama-tickets/src/session-manager.ts` | SDK: target-side session lifecycle |
292
+ | `packages/portlama-tickets/src/types.ts` | SDK: shared type definitions |
256
293
 
257
294
  ## Quick Reference
258
295
 
@@ -263,7 +300,7 @@ The integration flow:
263
300
  | ID length | 64 hex characters (256-bit) |
264
301
  | Expiry | 30 seconds |
265
302
  | Usage | Single-use |
266
- | Comparison | Timing-safe (`crypto.timingSafeEqual`) |
303
+ | Comparison | HMAC-SHA256 + `crypto.timingSafeEqual` (per-process random key) |
267
304
  | Rate limit | 10 per agent per minute |
268
305
 
269
306
  ### Instance lifecycle
@@ -274,7 +311,7 @@ The integration flow:
274
311
  | Heartbeat | stays active | `lastHeartbeat` updated |
275
312
  | 5 min no beat | → stale | New tickets rejected |
276
313
  | 1 hr no beat | → dead | Removed; assignments, tickets, sessions cleaned |
277
- | Deregistration | removed | Same cleanup as dead |
314
+ | Deregistration | removed | Same cleanup as dead; SDK calls `DELETE` on `stop()` |
278
315
 
279
316
  ### Session states
280
317
 
@@ -464,11 +464,14 @@ Agent-to-agent authorization with scopes, instances, tickets, and sessions. Prov
464
464
  - **Scope registry** — register/unregister capability sets with transport configuration
465
465
  - **Instance management** — register, heartbeat, deregister with liveness tracking (active → stale → dead)
466
466
  - **Assignment management** — link agents to instances (admin-only)
467
- - **Ticket operations** — request, validate (timing-safe), revoke with rate limiting (10/agent/min) and hard caps (200 instances, 1000 tickets, 500 sessions)
468
- - **Session management** — create from validated ticket, heartbeat with multi-layer re-validation, status updates
467
+ - **Ticket operations** — request, validate (HMAC-SHA256 + `timingSafeEqual` with per-process random key), revoke with rate limiting (10/agent/min) and hard caps (200 instances, 1000 tickets, 500 sessions)
468
+ - **Session management** — create from validated ticket (server-generated session IDs via `crypto.randomBytes(16)`, server-enforced timestamps), heartbeat with multi-layer re-validation, status updates
469
+ - **Host validation** — `transport.direct.host` rejects private/reserved IPs and cloud metadata endpoints (SSRF prevention)
469
470
  - **Periodic cleanup** — stale/dead instances, expired tickets, dead sessions
470
471
  - **Concurrency** — promise-chain mutex with atomic file writes (temp → fsync → rename)
471
472
 
473
+ Client SDK: `@lamalibre/portlama-tickets` (TypeScript, undici) provides `TicketClient`, `TicketInstanceManager` (source side), and `TicketSessionManager` (target side) for plugin integration.
474
+
472
475
  State files: `/etc/portlama/ticket-scopes.json` (scopes, instances, assignments) and `/etc/portlama/tickets.json` (tickets, sessions).
473
476
 
474
477
  ### services.js — Service Management
@@ -111,11 +111,23 @@ Register an instance offering a specific scope. Idempotent: re-registration with
111
111
 
112
112
  | Field | Type | Required | Description |
113
113
  | ------------------------- | -------- | -------- | ------------------------------------------ |
114
- | `scope` | string | Yes | Capability name (e.g., `shell:connect`) |
114
+ | `scope` | string | Yes | Capability name in `scope:action` format (e.g., `shell:connect`) |
115
115
  | `transport` | object | Yes | Transport configuration |
116
116
  | `transport.strategies` | string[] | Yes | Array of `"tunnel"`, `"relay"`, `"direct"` |
117
117
  | `transport.preferred` | string | No | Preferred strategy |
118
- | `transport.direct` | object | No | Direct connection details (host, port) |
118
+ | `transport.direct` | object | No | Direct connection details |
119
+ | `transport.direct.host` | string | Yes* | Public hostname or IP (1-255 chars). Private/reserved IPs are rejected (see below) |
120
+ | `transport.direct.port` | number | Yes* | Port number (1024-65535) |
121
+
122
+ \* Required when `transport.direct` is provided.
123
+
124
+ **Host validation:** The `transport.direct.host` field rejects private and reserved addresses to prevent SSRF. The following are rejected with a 400 error:
125
+
126
+ - Private IPv4 ranges: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
127
+ - Link-local: `169.254.0.0/16`
128
+ - Loopback: `localhost`, `127.0.0.1`, `::1`
129
+ - Metadata endpoints: `169.254.169.254`, `metadata.google.internal`
130
+ - Zero network: `0.0.0.0/8`
119
131
 
120
132
  **Response (201 new, 200 re-registration):**
121
133
 
@@ -127,7 +139,7 @@ Register an instance offering a specific scope. Idempotent: re-registration with
127
139
  }
128
140
  ```
129
141
 
130
- **Errors:** 403 (insufficient capability), 404 (scope not found), 503 (hard cap: 200 instances)
142
+ **Errors:** 400 (missing agent label, or private/reserved IP in `transport.direct.host`), 403 (insufficient capability), 404 (scope not found), 503 (hard cap: 200 instances)
131
143
 
132
144
  ---
133
145
 
@@ -164,7 +176,7 @@ POST /api/tickets/instances/:instanceId/heartbeat
164
176
 
165
177
  **Auth:** Admin or owning Agent
166
178
 
167
- Updates the instance's `lastHeartbeat` timestamp and maintains active status.
179
+ Updates the instance's `lastHeartbeat` timestamp and resets status to active. Re-validates that the owning agent still has the scope capability — returns 404 if the agent is revoked or the capability has been removed.
168
180
 
169
181
  **Response (200):**
170
182
 
@@ -174,7 +186,7 @@ Updates the instance's `lastHeartbeat` timestamp and maintains active status.
174
186
  }
175
187
  ```
176
188
 
177
- **Errors:** 404 (not found)
189
+ **Errors:** 400 (missing agent label), 404 (not found, or agent revoked/lacks capability)
178
190
 
179
191
  ---
180
192
 
@@ -209,7 +221,7 @@ Assign an agent to an instance scope, granting it permission to receive tickets
209
221
  "ok": true,
210
222
  "assignment": {
211
223
  "agentLabel": "linux-agent",
212
- "instanceScope": "shell:connect:a7f3b2c9d1e2f3a4",
224
+ "instanceScope": "shell:connect:a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
213
225
  "assignedAt": "2026-03-26T10:10:00.000Z",
214
226
  "assignedBy": "admin"
215
227
  }
@@ -296,7 +308,7 @@ Request a ticket to authorize communication with a target agent.
296
308
  "ticket": {
297
309
  "id": "64-hex-char-ticket-id",
298
310
  "scope": "shell:connect",
299
- "instanceId": "a7f3b2c9d1e2f3a4",
311
+ "instanceId": "a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
300
312
  "source": "macbook-pro",
301
313
  "target": "linux-agent",
302
314
  "expiresAt": "2026-03-26T10:15:30.000Z"
@@ -326,7 +338,7 @@ Returns non-expired, unused tickets where the caller is the target.
326
338
  {
327
339
  "id": "...",
328
340
  "scope": "shell:connect",
329
- "instanceId": "a7f3b2c9d1e2f3a4",
341
+ "instanceId": "a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
330
342
  "source": "macbook-pro",
331
343
  "expiresAt": "2026-03-26T10:15:30.000Z",
332
344
  "transport": {}
@@ -361,7 +373,7 @@ Validate and consume a ticket. This is an atomic operation — the ticket is mar
361
373
  {
362
374
  "valid": true,
363
375
  "scope": "shell:connect",
364
- "instanceId": "a7f3b2c9d1e2f3a4",
376
+ "instanceId": "a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
365
377
  "source": "macbook-pro",
366
378
  "target": "linux-agent",
367
379
  "transport": {}
@@ -424,14 +436,13 @@ POST /api/tickets/sessions
424
436
 
425
437
  **Auth:** Admin or Agent (requires `certLabel`)
426
438
 
427
- Create a session from a validated (used) ticket. The caller must be the ticket's target.
439
+ Create a session from a validated (used) ticket. The caller must be the ticket's target. The server generates the `sessionId` — clients do not provide it.
428
440
 
429
441
  **Request body:**
430
442
 
431
443
  | Field | Type | Required | Description |
432
444
  | ----------- | ------ | -------- | ---------------------------------------------- |
433
445
  | `ticketId` | string | Yes | Hex ticket ID (1-128 chars) |
434
- | `sessionId` | string | Yes | Caller-chosen session ID (1-128 chars, alphanumeric + `-_`) |
435
446
 
436
447
  **Response (201):**
437
448
 
@@ -442,7 +453,7 @@ Create a session from a validated (used) ticket. The caller must be the ticket's
442
453
  "sessionId": "...",
443
454
  "ticketId": "...",
444
455
  "scope": "shell:connect",
445
- "instanceId": "a7f3b2c9d1e2f3a4",
456
+ "instanceId": "a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
446
457
  "source": "macbook-pro",
447
458
  "target": "linux-agent",
448
459
  "createdAt": "2026-03-26T10:15:30.000Z",
@@ -483,7 +494,7 @@ Re-validates the session's authorization and updates activity timestamp.
483
494
  }
484
495
  ```
485
496
 
486
- Or if authorization failed:
497
+ Or if authorization failed (session is terminated):
487
498
 
488
499
  ```json
489
500
  {
@@ -492,7 +503,9 @@ Or if authorization failed:
492
503
  }
493
504
  ```
494
505
 
495
- **Errors:** 404 (session not found)
506
+ Possible `reason` values: `admin_killed`, `source_revoked`, `capability_removed`, `target_revoked`, `assignment_removed`.
507
+
508
+ **Errors:** 400 (missing certLabel), 404 (session not found)
496
509
 
497
510
  ---
498
511
 
@@ -502,16 +515,17 @@ Or if authorization failed:
502
515
  PATCH /api/tickets/sessions/:sessionId
503
516
  ```
504
517
 
505
- **Auth:** Admin or Agent (requires `certLabel`)
518
+ **Auth:** Admin or Agent (requires `certLabel` — caller can be either the session's source or target)
506
519
 
507
- Update session status (e.g., entering grace period for reconnection).
520
+ Update session status (e.g., entering grace period for reconnection). Re-validates authorization on every status transition: checks that the source certificate is not revoked, source still has the scope capability, and the target's assignment is still valid. If any check fails, the session is terminated and the endpoint returns 409.
508
521
 
509
522
  **Request body:**
510
523
 
511
524
  | Field | Type | Required | Description |
512
525
  | ---------------- | ------ | -------- | ---------------------------------- |
513
526
  | `status` | string | Yes | `"active"` or `"grace"` |
514
- | `lastActivityAt` | string | No | ISO 8601 datetime |
527
+
528
+ The server sets `lastActivityAt` automatically — clients cannot provide or override this field.
515
529
 
516
530
  **Response (200):**
517
531
 
@@ -521,7 +535,7 @@ Update session status (e.g., entering grace period for reconnection).
521
535
  }
522
536
  ```
523
537
 
524
- **Errors:** 404 (session not found), 409 (cannot reactivate dead session)
538
+ **Errors:** 400 (missing certLabel), 404 (session not found), 409 (session is terminated — dead, or authorization re-validation failed)
525
539
 
526
540
  ---
527
541
 
@@ -222,6 +222,7 @@ Stores the ticket scope registry: registered scopes, active instances, and agent
222
222
  "description": "Remote shell access",
223
223
  "scopes": [{ "name": "shell:connect", "description": "Connect to shell", "instanceScoped": true }],
224
224
  "transport": { "strategies": ["tunnel"], "preferred": "tunnel", "port": 9000, "protocol": "wss" },
225
+ "hooks": {}, // Reserved for future hook configuration
225
226
  "installedAt": "2026-03-26T10:00:00.000Z"
226
227
  }
227
228
  ],
@@ -239,7 +240,7 @@ Stores the ticket scope registry: registered scopes, active instances, and agent
239
240
  "assignments": [
240
241
  {
241
242
  "agentLabel": "linux-agent",
242
- "instanceScope": "shell:connect:a7f3b2c9d1e2f3a4",
243
+ "instanceScope": "shell:connect:a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
243
244
  "assignedAt": "2026-03-26T10:10:00.000Z",
244
245
  "assignedBy": "admin"
245
246
  }
@@ -247,6 +248,22 @@ Stores the ticket scope registry: registered scopes, active instances, and agent
247
248
  }
248
249
  ```
249
250
 
251
+ **Instance transport sub-schema:**
252
+
253
+ The instance `transport` object may include a `direct` sub-object when the `direct` strategy is listed:
254
+
255
+ | Field | Type | Required | Description |
256
+ | -------------------------- | -------- | -------- | --------------------------------------------------------------- |
257
+ | `transport.strategies` | string[] | Yes | Array of `"tunnel"`, `"relay"`, `"direct"` |
258
+ | `transport.preferred` | string | No | Preferred strategy (must be in `strategies`) |
259
+ | `transport.direct` | object | No | Direct connection details (required when using `direct` strategy) |
260
+ | `transport.direct.host` | string | Yes* | Public hostname or IP (1-255 chars). Private/reserved IPs rejected (SSRF prevention) |
261
+ | `transport.direct.port` | number | Yes* | Port number (1024-65535) |
262
+
263
+ \* Required when `transport.direct` is provided.
264
+
265
+ **Host validation:** The `transport.direct.host` field rejects private and reserved addresses: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, loopback (`localhost`, `127.0.0.1`, `::1`), cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`), and the zero network (`0.0.0.0/8`).
266
+
250
267
  **Write pattern:** Atomic — temp file, `fsync()`, `rename()`. Concurrency controlled by promise-chain mutex.
251
268
 
252
269
  ---
@@ -260,7 +277,7 @@ Stores active tickets and sessions for agent-to-agent authorization. Created aut
260
277
  | Field | Type | Description |
261
278
  | ---------- | ----- | -------------------------------------------------------------- |
262
279
  | `tickets` | array | Issued tickets (id, scope, instanceId, source, target, expiry) |
263
- | `sessions` | array | Active sessions (sessionId, ticketId, status, heartbeat) |
280
+ | `sessions` | array | Active sessions (server-generated sessionId, ticketId, status, heartbeat) |
264
281
 
265
282
  **Example:**
266
283
 
@@ -270,7 +287,7 @@ Stores active tickets and sessions for agent-to-agent authorization. Created aut
270
287
  {
271
288
  "id": "64-hex-char-ticket-id",
272
289
  "scope": "shell:connect",
273
- "instanceId": "a7f3b2c9d1e2f3a4",
290
+ "instanceId": "a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
274
291
  "source": "macbook-pro",
275
292
  "target": "linux-agent",
276
293
  "createdAt": "2026-03-26T10:15:00.000Z",
@@ -283,10 +300,10 @@ Stores active tickets and sessions for agent-to-agent authorization. Created aut
283
300
  ],
284
301
  "sessions": [
285
302
  {
286
- "sessionId": "session-1",
303
+ "sessionId": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
287
304
  "ticketId": "64-hex-char-ticket-id",
288
305
  "scope": "shell:connect",
289
- "instanceId": "a7f3b2c9d1e2f3a4",
306
+ "instanceId": "a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
290
307
  "source": "macbook-pro",
291
308
  "target": "linux-agent",
292
309
  "createdAt": "2026-03-26T10:15:30.000Z",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/portlama-panel-server",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Portlama management panel backend",
5
5
  "private": true,
6
6
  "type": "module",
@@ -60,7 +60,7 @@ async function getInstalledVersion() {
60
60
  * Write content to a system path using a temp file and sudo mv.
61
61
  */
62
62
  async function sudoWriteFile(destPath, content, mode = '644') {
63
- const tmpFile = path.join(tmpdir(), `portlama-${crypto.randomBytes(4).toString('hex')}`);
63
+ const tmpFile = path.join(tmpdir(), `portlama-authelia-${crypto.randomBytes(4).toString('hex')}`);
64
64
  await fsWriteFile(tmpFile, content, 'utf-8');
65
65
  await execa('sudo', ['mv', tmpFile, destPath]);
66
66
  await execa('sudo', ['chmod', mode, destPath]);
@@ -100,7 +100,7 @@ export async function issueAppCert(subdomain, domain, email) {
100
100
  export async function listCerts() {
101
101
  let stdout;
102
102
  try {
103
- const result = await execa('sudo', ['certbot', 'certificates']);
103
+ const result = await execa('sudo', ['certbot', 'certificates', '--non-interactive']);
104
104
  stdout = result.stdout;
105
105
  } catch (err) {
106
106
  // certbot certificates returns non-zero if no certs exist
@@ -165,10 +165,15 @@ export async function listCerts() {
165
165
  * Renew a specific certificate by name.
166
166
  *
167
167
  * @param {string} domain - Certificate name (usually the domain)
168
+ * @param {{ forceRenewal?: boolean }} [options]
168
169
  */
169
- export async function renewCert(domain) {
170
+ export async function renewCert(domain, options = {}) {
171
+ const args = ['certbot', 'renew', '--cert-name', domain];
172
+ if (options.forceRenewal) args.push('--force-renewal');
173
+ args.push('--non-interactive');
174
+
170
175
  try {
171
- await execa('sudo', ['certbot', 'renew', '--cert-name', domain]);
176
+ await execa('sudo', args);
172
177
  return { renewed: true, domain };
173
178
  } catch (err) {
174
179
  throw new Error(`Failed to renew certificate for ${domain}: ${err.stderr || err.message}`);
@@ -180,7 +185,7 @@ export async function renewCert(domain) {
180
185
  */
181
186
  export async function renewAll() {
182
187
  try {
183
- const { stdout } = await execa('sudo', ['certbot', 'renew']);
188
+ const { stdout } = await execa('sudo', ['certbot', 'renew', '--non-interactive']);
184
189
  return { renewed: true, output: stdout };
185
190
  } catch (err) {
186
191
  throw new Error(`Failed to renew certificates: ${err.stderr || err.message}`);
@@ -90,13 +90,32 @@ export const RegisterScopeSchema = z.object({
90
90
  transport: TransportSchema,
91
91
  });
92
92
 
93
+ // Hostname/IP validation: reject private, loopback, link-local, and metadata IPs
94
+ const HostnameSchema = z.string().min(1).max(255).refine((host) => {
95
+ // Block metadata endpoint (AWS/GCP/Azure)
96
+ if (host === '169.254.169.254' || host === 'metadata.google.internal') return false;
97
+ // Block loopback
98
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return false;
99
+ // Block IPv4 private ranges and link-local
100
+ const ipv4Match = host.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
101
+ if (ipv4Match) {
102
+ const [, a, b] = ipv4Match.map(Number);
103
+ if (a === 10) return false; // 10.0.0.0/8
104
+ if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12
105
+ if (a === 192 && b === 168) return false; // 192.168.0.0/16
106
+ if (a === 169 && b === 254) return false; // 169.254.0.0/16 link-local
107
+ if (a === 0) return false; // 0.0.0.0/8
108
+ }
109
+ return true;
110
+ }, { message: 'Host must be a public hostname or IP address' });
111
+
93
112
  export const RegisterInstanceSchema = z.object({
94
113
  scope: CapabilityStringSchema,
95
114
  transport: z.object({
96
115
  strategies: z.array(TransportStrategySchema).min(1),
97
116
  preferred: TransportStrategySchema.optional(),
98
117
  direct: z.object({
99
- host: z.string().min(1).max(255),
118
+ host: HostnameSchema,
100
119
  port: z.number().int().min(1024).max(65535),
101
120
  }).optional(),
102
121
  }),
@@ -114,12 +133,10 @@ export const ValidateTicketSchema = z.object({
114
133
 
115
134
  export const CreateSessionSchema = z.object({
116
135
  ticketId: z.string().min(1).max(128).regex(/^[a-f0-9]+$/),
117
- sessionId: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/),
118
136
  });
119
137
 
120
138
  export const UpdateSessionSchema = z.object({
121
139
  status: z.enum(['active', 'grace']),
122
- lastActivityAt: z.string().datetime().optional(),
123
140
  });
124
141
 
125
142
  export const AssignmentSchema = z.object({
@@ -213,10 +230,15 @@ function cleanTickets(store) {
213
230
 
214
231
  // --- Timing-safe comparison ---
215
232
 
233
+ // Per-process random key prevents pre-computation if source is read
234
+ const COMPARE_KEY = crypto.randomBytes(32);
235
+
216
236
  function safeCompare(a, b) {
217
237
  if (typeof a !== 'string' || typeof b !== 'string') return false;
218
- if (a.length !== b.length) return false;
219
- return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
238
+ // HMAC both values to get fixed-length digests, avoiding length-leak in timingSafeEqual.
239
+ const ha = crypto.createHmac('sha256', COMPARE_KEY).update(a).digest();
240
+ const hb = crypto.createHmac('sha256', COMPARE_KEY).update(b).digest();
241
+ return crypto.timingSafeEqual(ha, hb);
220
242
  }
221
243
 
222
244
  // --- Rate limiting ---
@@ -817,7 +839,7 @@ export function revokeTicket(ticketId, logger) {
817
839
 
818
840
  // --- Session management ---
819
841
 
820
- export function createSession(ticketId, sessionId, callerLabel, logger) {
842
+ export function createSession(ticketId, callerLabel, logger) {
821
843
  return withTicketLock(async () => {
822
844
  const store = await loadTicketStore();
823
845
 
@@ -836,6 +858,8 @@ export function createSession(ticketId, sessionId, callerLabel, logger) {
836
858
  throw Object.assign(new Error('Session limit reached'), { statusCode: 503 });
837
859
  }
838
860
 
861
+ // Generate session ID server-side for uniqueness guarantee
862
+ const sessionId = crypto.randomBytes(16).toString('hex');
839
863
  const now = new Date().toISOString();
840
864
  const session = {
841
865
  sessionId,
@@ -936,7 +960,7 @@ export function sessionHeartbeat(sessionId, callerLabel) {
936
960
  });
937
961
  }
938
962
 
939
- export function updateSession(sessionId, status, lastActivityAt, callerLabel) {
963
+ export function updateSession(sessionId, status, callerLabel) {
940
964
  return withTicketLock(async () => {
941
965
  const store = await loadTicketStore();
942
966
  const session = store.sessions.find(
@@ -988,7 +1012,8 @@ export function updateSession(sessionId, status, lastActivityAt, callerLabel) {
988
1012
  }
989
1013
 
990
1014
  session.status = status;
991
- if (lastActivityAt) session.lastActivityAt = lastActivityAt;
1015
+ // Always set server-side timestamp to prevent clients from extending session lifetime
1016
+ session.lastActivityAt = new Date().toISOString();
992
1017
  await saveTicketStore(store);
993
1018
 
994
1019
  return { ok: true };
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { execa } from 'execa';
3
- import { listCerts } from '../../lib/certbot.js';
3
+ import { listCerts, renewCert } from '../../lib/certbot.js';
4
4
  import {
5
5
  getMtlsCerts,
6
6
  readCertExpiry,
@@ -577,21 +577,17 @@ export default async function certsRoutes(fastify, _opts) {
577
577
  const { domain } = params;
578
578
 
579
579
  try {
580
- // Force renewal via certbot
581
- await execa('sudo', ['certbot', 'renew', '--cert-name', domain, '--force-renewal'], {
582
- timeout: 90000,
583
- });
580
+ await renewCert(domain, { forceRenewal: true });
584
581
  } catch (err) {
585
- const stderr = err.stderr || err.message;
582
+ const msg = err.message || '';
586
583
 
587
- if (stderr.includes('No certificate found') || stderr.includes('not found')) {
584
+ if (msg.includes('No certificate found') || msg.includes('not found')) {
588
585
  return reply.code(404).send({ error: 'Certificate not found' });
589
586
  }
590
587
 
591
588
  request.log.error({ err, domain }, 'Certificate renewal failed');
592
589
  return reply.code(500).send({
593
590
  error: 'Certificate renewal failed',
594
- details: stderr,
595
591
  });
596
592
  }
597
593
 
@@ -155,6 +155,14 @@ export default async function ticketRoutes(fastify, _opts) {
155
155
  if (!agentLabel) {
156
156
  return reply.code(400).send({ error: 'Agent label required' });
157
157
  }
158
+ // Defense-in-depth: reject agents with no capabilities before acquiring
159
+ // the lock. The library's instanceHeartbeat verifies the specific scope.
160
+ if (request.certRole === 'agent') {
161
+ const caps = request.certCapabilities || [];
162
+ if (caps.length === 0) {
163
+ return reply.code(404).send({ error: 'Not found' });
164
+ }
165
+ }
158
166
  const result = await instanceHeartbeat(instanceId, agentLabel);
159
167
  return result;
160
168
  } catch (err) {
@@ -342,7 +350,7 @@ export default async function ticketRoutes(fastify, _opts) {
342
350
  if (!callerLabel) {
343
351
  return reply.code(400).send({ error: 'Agent label required' });
344
352
  }
345
- const result = await createSession(body.ticketId, body.sessionId, callerLabel, request.log);
353
+ const result = await createSession(body.ticketId, callerLabel, request.log);
346
354
  return reply.code(201).send(result);
347
355
  } catch (err) {
348
356
  const statusCode = err.statusCode || 500;
@@ -386,7 +394,6 @@ export default async function ticketRoutes(fastify, _opts) {
386
394
  const result = await updateSession(
387
395
  sessionId,
388
396
  body.status,
389
- body.lastActivityAt,
390
397
  callerLabel,
391
398
  );
392
399
  return result;