@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 +1 -1
- package/src/lib/service-config.js +25 -20
- package/vendor/panel-client/dist/docs/01-concepts/tickets.md +48 -11
- package/vendor/panel-client/dist/docs/03-architecture/panel-server.md +5 -2
- package/vendor/panel-client/dist/docs/04-api-reference/tickets.md +32 -18
- package/vendor/panel-client/dist/docs/06-reference/config-files.md +22 -5
- package/vendor/panel-server/package.json +1 -1
- package/vendor/panel-server/src/lib/authelia.js +1 -1
- package/vendor/panel-server/src/lib/certbot.js +9 -4
- package/vendor/panel-server/src/lib/tickets.js +33 -8
- package/vendor/panel-server/src/routes/management/certs.js +4 -8
- package/vendor/panel-server/src/routes/management/tickets.js +9 -2
package/package.json
CHANGED
|
@@ -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
|
|
78
|
-
|
|
79
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/certbot
|
|
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:
|
|
85
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/openssl x509 -in /etc/portlama/pki/*
|
|
86
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/openssl x509 -in /etc/
|
|
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
|
|
93
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp
|
|
94
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp
|
|
95
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp
|
|
96
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp
|
|
97
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp
|
|
98
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp
|
|
99
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp
|
|
100
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp
|
|
101
|
-
portlama ALL=(root) NOPASSWD: /usr/bin/mv /tmp
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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": "
|
|
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": "
|
|
252
|
+
"sessionId": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
|
|
234
253
|
"ticketId": "64-hex-char-ticket-id",
|
|
235
254
|
"scope": "shell:connect",
|
|
236
|
-
"instanceId": "
|
|
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 |
|
|
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 (
|
|
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
|
|
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
|
|
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:
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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": "
|
|
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": "
|
|
303
|
+
"sessionId": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
|
|
287
304
|
"ticketId": "64-hex-char-ticket-id",
|
|
288
305
|
"scope": "shell:connect",
|
|
289
|
-
"instanceId": "
|
|
306
|
+
"instanceId": "a7f3b2c9d1e2f3a4b5c6d7e8f9a0b1c2",
|
|
290
307
|
"source": "macbook-pro",
|
|
291
308
|
"target": "linux-agent",
|
|
292
309
|
"createdAt": "2026-03-26T10:15:30.000Z",
|
|
@@ -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',
|
|
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:
|
|
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
|
-
|
|
219
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
582
|
+
const msg = err.message || '';
|
|
586
583
|
|
|
587
|
-
if (
|
|
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,
|
|
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;
|