portacode 1.4.15.dev3__py3-none-any.whl → 1.4.15.dev10__py3-none-any.whl

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.
portacode/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.4.15.dev3'
32
- __version_tuple__ = version_tuple = (1, 4, 15, 'dev3')
31
+ __version__ = version = '1.4.15.dev10'
32
+ __version_tuple__ = version_tuple = (1, 4, 15, 'dev10')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -27,6 +27,10 @@ The Portacode server acts as a **routing middleman** between client sessions and
27
27
 
28
28
  - **`source_client_session`** (Server → Device): Server **adds this** when forwarding client commands to devices (so device knows which client sent the command and can target responses back). Clients never include this field.
29
29
 
30
+ ### Proxying infrastructure updates
31
+
32
+ Portacode infrastructure devices (like the proxmox host) can send events on behalf of the LXC Devices they manage. Such messages include the optional `on_behalf_of_device` field and the server silently replaces `device_id` with that child device before routing. The gateway enforces that the sender is the child’s `proxmox_parent` (via `Device.proxmox_parent`) so only the infrastructure owner can impersonate a child device. Messages that fail this check are dropped.
33
+
30
34
  This document describes the complete protocol for communicating with devices through the server, guiding app developers on how to get their client sessions to communicate with devices.
31
35
 
32
36
  ## Table of Contents
@@ -326,12 +330,9 @@ Configures a Proxmox node for Portacode infrastructure usage (API token validati
326
330
 
327
331
  **Payload Fields:**
328
332
 
329
- * `token_identifier` (string, optional when reconfiguring): API token identifier in the form `user@realm!tokenid`. Required on first configuration or when replacing the stored token.
330
- * `token_value` (string, optional when reconfiguring): Secret value associated with the token. Required when `token_identifier` is supplied.
331
- * `verify_ssl` (boolean, optional): When true, the handler verifies SSL certificates; defaults to `false`. When omitted, the last configured value is preserved.
332
- * `cloudflare_api_token` (string, optional): Cloudflare API token the host can reuse later to provision tunnels.
333
-
334
- The setup command also installs `cloudflared` on the node so Cloudflare tunnels can be created afterward.
333
+ * `token_identifier` (string, required): API token identifier in the form `user@realm!tokenid`.
334
+ * `token_value` (string, required): Secret value associated with the token.
335
+ * `verify_ssl` (boolean, optional): When true, the handler verifies SSL certificates; defaults to `false`.
335
336
 
336
337
  **Responses:**
337
338
 
@@ -364,7 +365,7 @@ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portaco
364
365
  * `username` (string, optional): OS user to provision (defaults to `svcuser`).
365
366
  * `password` (string, optional): Password for the user (used only during provisioning).
366
367
  * `ssh_key` (string, optional): SSH public key to add to the user.
367
- * `device_id` (string, optional): ID of the Device record that already exists on the dashboard.
368
+ * `device_id` (string, required): ID of the dashboard Device record that represents the container. The handler persists this value in the host metadata file so related events can always be correlated back to that Device.
368
369
  * `device_public_key` (string, optional): PEM-encoded Portacode public key. When supplied together with `device_private_key` the handler injects the keypair, records the device metadata, and runs `portacode service install` automatically.
369
370
  * `device_private_key` (string, optional): PEM-encoded private key that pairs with `device_public_key`. Both key fields must be present for the automatic service-install mode.
370
371
 
@@ -412,51 +413,6 @@ Deletes a managed container from Proxmox (stopping it first if necessary) and re
412
413
  * Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="remove"` and the refreshed infra snapshot after deletion.
413
414
  * Emits an [`error`](#error) event on failure.
414
415
 
415
- ### `create_cloudflare_tunnel`
416
-
417
- Creates or updates a Cloudflare tunnel in front of a managed container. The container must be running and a Cloudflare API token must already be configured via `setup_proxmox_infra`.
418
-
419
- **Payload Fields:**
420
-
421
- * `ctid` (string, required): Identifier of the container.
422
- * `container_port` (integer, required): Port inside the container to expose through Cloudflare.
423
- * `cloudflare_url` (string, optional): Hostname (e.g., `app.example.com`) that the tunnel should serve. Leave blank to let Cloudflare create a quick tunnel (`*.cfargotunnel.com`) automatically.
424
- * `protocol` (string, optional): One of `http`, `https`, or `tcp` (defaults to `http`).
425
-
426
- **Responses:**
427
-
428
- * Emits a [`cloudflare_tunnel_created`](#cloudflare_tunnel_created-event) event with the tunnel metadata, the refreshed infra snapshot, and the optional `device_id` so dashboards can refresh the CT list.
429
- * Emits an [`error`](#error) event on failure.
430
-
431
- ### `update_cloudflare_tunnel`
432
-
433
- Refreshes the tunnel configuration for an existing tunnel (different port, URL, or protocol).
434
-
435
- **Payload Fields:**
436
-
437
- * `ctid` (string, required): Identifier of the container.
438
- * `container_port` (integer, optional): New container port.
439
- * `cloudflare_url` (string, optional): New hostname (leave blank to keep the current hostname or let Cloudflare assign a quick tunnel).
440
- * `protocol` (string, optional): New protocol (`http`, `https`, or `tcp`).
441
-
442
- **Responses:**
443
-
444
- * Emits a [`cloudflare_tunnel_updated`](#cloudflare_tunnel_updated-event) event with the refreshed tunnel metadata, the refreshed infra snapshot, and the optional `device_id`.
445
- * Emits an [`error`](#error) event on failure.
446
-
447
- ### `remove_cloudflare_tunnel`
448
-
449
- Stops and removes any tunnel metadata associated with a container.
450
-
451
- **Payload Fields:**
452
-
453
- * `ctid` (string, required): Identifier of the container.
454
-
455
- **Responses:**
456
-
457
- * Emits a [`cloudflare_tunnel_removed`](#cloudflare_tunnel_removed-event) event with the refreshed infra snapshot and optional `device_id`.
458
- * Emits an [`error`](#error) event on failure.
459
-
460
416
  ### `proxmox_container_created`
461
417
 
462
418
  Emitted after a successful `create_proxmox_container` action. Contains the new container ID, the Portacode public key produced inside the container, and the bootstrap logs.
@@ -469,7 +425,8 @@ Emitted after a successful `create_proxmox_container` action. Contains the new c
469
425
  * `public_key` (string): Portacode public auth key created inside the new container.
470
426
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
471
427
  * `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
472
- * `device_id` (string, optional): Mirrors the `device_id` supplied with `create_proxmox_container`, if any.
428
+ * `device_id` (string): Mirrors the dashboard Device ID supplied with `create_proxmox_container`. The handler records this value in the container metadata file so subsequent events can reference the same Device.
429
+ * `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device; only proxmox parents may include this field.
473
430
  * `service_installed` (boolean): True when the handler already ran `portacode service install` (with a provided keypair); otherwise it remains False and the dashboard can call `start_portacode_service`.
474
431
 
475
432
  ### `proxmox_container_progress`
@@ -487,6 +444,7 @@ Sent intermittently while `create_proxmox_container` is executing so callers can
487
444
  * `message` (string): Short description of what is happening or why a failure occurred.
488
445
  * `details` (object, optional): Contains `attempt` (if retries were needed) and `error_summary` when a step fails.
489
446
  * `request_id` (string, optional): Mirrors the request ID from the incoming `create_proxmox_container` payload when available.
447
+ * `on_behalf_of_device` (string, optional): When present the proxmox device is reporting progress for the referenced dashboard device; the gateway verifies the proxmox node is the child’s `proxmox_parent` before routing the event.
490
448
 
491
449
  ### `start_portacode_service`
492
450
 
@@ -1174,8 +1132,6 @@ Provides system information in response to a `system_info` action. Handled by [`
1174
1132
  * `message` (string|null): Informational text about the network setup attempt.
1175
1133
  * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1176
1134
  * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
1177
- * `cloudflare` (object): Optional Cloudflare metadata collected for future tunnels:
1178
- * `configured` (boolean): True when a Cloudflare API token is stored.
1179
1135
  * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
1180
1136
  * `managed_containers` (object): Cached summary of the Portacode-managed containers:
1181
1137
  * `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
@@ -1193,11 +1149,6 @@ Provides system information in response to a `system_info` action. Handled by [`
1193
1149
  * `cpu_share` (number): vCPU-equivalent share requested at creation.
1194
1150
  * `status` (string): Lowercase lifecycle status (e.g., `running`, `stopped`, `deleted`).
1195
1151
  * `created_at` (string|null): ISO timestamp recorded when the CT was provisioned.
1196
- * `tunnel` (object|null): Tunnel metadata that includes:
1197
- * `url` (string): Public hostname assigned for this tunnel.
1198
- * `container_port` (integer): Container port exposed via the tunnel.
1199
- * `protocol` (string): Protocol advertised when the tunnel was configured.
1200
- * `status` (string): `running`, `stopped`, or `unknown`.
1201
1152
  * `portacode_version` (string): Installed CLI version returned by `portacode.__version__`.
1202
1153
 
1203
1154
  ### `proxmox_infra_configured`
@@ -1232,6 +1183,8 @@ Emitted after a successful `create_proxmox_container` action to report the newly
1232
1183
  * `public_key` (string): Portacode public auth key discovered inside the container.
1233
1184
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
1234
1185
  * `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
1186
+ * `device_id` (string): Mirrors the device ID supplied with `create_proxmox_container` and persisted inside the host metadata file for this CT.
1187
+ * `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device.
1235
1188
 
1236
1189
  ### `proxmox_container_progress`
1237
1190
 
@@ -1248,6 +1201,7 @@ Sent continuously while `create_proxmox_container` runs so dashboards can show a
1248
1201
  * `message` (string): Short human-readable description of the action or failure.
1249
1202
  * `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
1250
1203
  * `request_id` (string, optional): Mirrors the `create_proxmox_container` request when provided.
1204
+ * `on_behalf_of_device` (string, optional): Mirrors the child device ID when a proxmox host reports progress for that child; only proxmox parents can supply this field.
1251
1205
 
1252
1206
  ### `proxmox_container_action`
1253
1207
 
@@ -1263,48 +1217,6 @@ Emitted after `start_proxmox_container`, `stop_proxmox_container`, or `remove_pr
1263
1217
  * `details` (object, optional): Exit status information (e.g., `exitstatus`, `stop_exitstatus`, `delete_exitstatus`).
1264
1218
  * `infra` (object): Same snapshot described under [`system_info`](#system_info-event) `proxmox.infra`, including the updated `managed_containers` summary.
1265
1219
 
1266
- ### `cloudflare_tunnel_created`
1267
-
1268
- Submitted after a successful `create_cloudflare_tunnel` action.
1269
-
1270
- **Event Fields:**
1271
-
1272
- * `event` (string): `cloudflare_tunnel_created`.
1273
- * `ctid` (string): Container ID associated with the tunnel.
1274
- * `success` (boolean): True when the tunnel is running.
1275
- * `device_id` (string, optional): Mirrors the `device_id` supplied with the command, letting dashboards refresh that device immediately.
1276
- * `message` (string): Summary text; when a hostname was assigned (including Quick Tunnel), the host is appended (e.g., `Created ... -> example.com`).
1277
- * `tunnel` (object): Tunnel metadata matching the manager view. When using Cloudflare Quick Tunnel (no hostname supplied), `tunnel.url` holds the autogenerated `*.cfargotunnel.com` hostname.
1278
- * `infra` (object): Snapshot produced by `get_infra_snapshot`, including the refreshed `managed_containers` list.
1279
-
1280
- ### `cloudflare_tunnel_updated`
1281
-
1282
- Sent when `update_cloudflare_tunnel` completes.
1283
-
1284
- **Event Fields:**
1285
-
1286
- * `event` (string): `cloudflare_tunnel_updated`.
1287
- * `ctid` (string): Container ID associated with the tunnel.
1288
- * `success` (boolean): True on success.
1289
- * `device_id` (string, optional): Mirrors the command's `device_id`.
1290
- * `message` (string): Summary text; includes the new hostname when one was assigned or changed.
1291
- * `tunnel` (object): Updated tunnel metadata.
1292
- * `infra` (object): Snapshot produced by `get_infra_snapshot` so dashboards can refresh the managed container list.
1293
-
1294
- ### `cloudflare_tunnel_removed`
1295
-
1296
- Sent after a tunnel has been removed from a container record.
1297
-
1298
- **Event Fields:**
1299
-
1300
- * `event` (string): `cloudflare_tunnel_removed`.
1301
- * `ctid` (string): Container ID that no longer has a tunnel.
1302
- * `success` (boolean): True on success.
1303
- * `device_id` (string, optional): Mirrors the command's `device_id`.
1304
- * `message` (string): Summary text.
1305
- * `tunnel` (null): Always `null` to explicitly clear the stored tunnel metadata.
1306
- * `infra` (object): The refreshed snapshot so dashboards see the updated container list.
1307
-
1308
1220
  ### <a name="clock_sync_response"></a>`clock_sync_response`
1309
1221
 
1310
1222
  Reply sent by the gateway immediately after receiving a `clock_sync_request`. Devices use this event plus the measured round-trip time to keep their local `ntp_clock` offset accurate.
@@ -43,15 +43,12 @@ from .project_state_handlers import (
43
43
  )
44
44
  from .proxmox_infra import (
45
45
  ConfigureProxmoxInfraHandler,
46
- CreateCloudflareTunnelHandler,
47
46
  CreateProxmoxContainerHandler,
48
47
  RevertProxmoxInfraHandler,
49
48
  StartPortacodeServiceHandler,
50
49
  StartProxmoxContainerHandler,
51
50
  StopProxmoxContainerHandler,
52
- RemoveCloudflareTunnelHandler,
53
51
  RemoveProxmoxContainerHandler,
54
- UpdateCloudflareTunnelHandler,
55
52
  )
56
53
 
57
54
  __all__ = [
@@ -65,7 +62,6 @@ __all__ = [
65
62
  "TerminalListHandler",
66
63
  "SystemInfoHandler",
67
64
  "ConfigureProxmoxInfraHandler",
68
- "CreateCloudflareTunnelHandler",
69
65
  "CreateProxmoxContainerHandler",
70
66
  # File operation handlers (optional - register as needed)
71
67
  "FileReadHandler",
@@ -95,9 +91,7 @@ __all__ = [
95
91
  "StartPortacodeServiceHandler",
96
92
  "StartProxmoxContainerHandler",
97
93
  "StopProxmoxContainerHandler",
98
- "RemoveCloudflareTunnelHandler",
99
94
  "RemoveProxmoxContainerHandler",
100
- "UpdateCloudflareTunnelHandler",
101
95
  "UpdatePortacodeHandler",
102
96
  "RevertProxmoxInfraHandler",
103
97
  ]
@@ -5,11 +5,10 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
+ import math
8
9
  import os
9
10
  import secrets
10
11
  import shlex
11
- import re
12
- import select
13
12
  import shutil
14
13
  import stat
15
14
  import subprocess
@@ -17,7 +16,6 @@ import sys
17
16
  import tempfile
18
17
  import time
19
18
  import threading
20
- import urllib.request
21
19
  from datetime import datetime
22
20
  from pathlib import Path
23
21
  from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
@@ -43,15 +41,13 @@ BRIDGE_IP = SUBNET_CIDR.split("/", 1)[0]
43
41
  DHCP_START = "10.10.0.100"
44
42
  DHCP_END = "10.10.0.200"
45
43
  DNS_SERVER = "1.1.1.1"
46
- CLOUDFLARE_DEB_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb"
47
44
  IFACES_PATH = Path("/etc/network/interfaces")
48
45
  SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
49
46
  UNIT_DIR = Path("/etc/systemd/system")
50
47
  _MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
51
48
  _MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
52
49
  _MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
53
- _CLOUDFLARE_TUNNEL_PROCESSES: Dict[str, subprocess.Popen] = {}
54
- _CLOUDFLARE_TUNNELS_LOCK = threading.Lock()
50
+ _STARTUP_TEMPLATES_REFRESHED = False
55
51
 
56
52
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
57
53
 
@@ -68,6 +64,7 @@ def _emit_progress_event(
68
64
  phase: str,
69
65
  request_id: Optional[str],
70
66
  details: Optional[Dict[str, Any]] = None,
67
+ on_behalf_of_device: Optional[str] = None,
71
68
  ) -> None:
72
69
  loop = handler.context.get("event_loop")
73
70
  if not loop or loop.is_closed():
@@ -92,6 +89,8 @@ def _emit_progress_event(
92
89
  payload["request_id"] = request_id
93
90
  if details:
94
91
  payload["details"] = details
92
+ if on_behalf_of_device:
93
+ payload["on_behalf_of_device"] = str(on_behalf_of_device)
95
94
 
96
95
  future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
97
96
  future.add_done_callback(
@@ -181,6 +180,41 @@ def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]])
181
180
  return templates
182
181
 
183
182
 
183
+ def _build_proxmox_client_from_config(config: Dict[str, Any]):
184
+ user = config.get("user")
185
+ token_name = config.get("token_name")
186
+ token_value = config.get("token_value")
187
+ if not user or not token_name or not token_value:
188
+ raise RuntimeError("Proxmox API credentials are missing")
189
+ ProxmoxAPI = _ensure_proxmoxer()
190
+ return ProxmoxAPI(
191
+ config.get("host", DEFAULT_HOST),
192
+ user=user,
193
+ token_name=token_name,
194
+ token_value=token_value,
195
+ verify_ssl=config.get("verify_ssl", False),
196
+ timeout=30,
197
+ )
198
+
199
+
200
+ def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
201
+ global _STARTUP_TEMPLATES_REFRESHED
202
+ if _STARTUP_TEMPLATES_REFRESHED or not config or not config.get("token_value"):
203
+ _STARTUP_TEMPLATES_REFRESHED = True
204
+ return
205
+ try:
206
+ client = _build_proxmox_client_from_config(config)
207
+ node = config.get("node") or _pick_node(client)
208
+ storages = client.nodes(node).storage.get()
209
+ templates = _list_templates(client, node, storages)
210
+ if templates:
211
+ config["templates"] = templates
212
+ except Exception as exc:
213
+ logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
214
+ finally:
215
+ _STARTUP_TEMPLATES_REFRESHED = True
216
+
217
+
184
218
  def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
185
219
  candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
186
220
  if not candidates:
@@ -285,25 +319,6 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
285
319
  return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
286
320
 
287
321
 
288
- def _ensure_cloudflared_installed() -> None:
289
- if shutil.which("cloudflared"):
290
- return
291
- apt = shutil.which("apt-get")
292
- if not apt:
293
- raise RuntimeError("cloudflared is missing and apt-get is unavailable to install it")
294
- download_dir = Path(tempfile.mkdtemp())
295
- deb_path = download_dir / "cloudflared.deb"
296
- try:
297
- urllib.request.urlretrieve(CLOUDFLARE_DEB_URL, deb_path)
298
- try:
299
- _call_subprocess(["dpkg", "-i", str(deb_path)], check=True)
300
- except subprocess.CalledProcessError:
301
- _call_subprocess([apt, "install", "-f", "-y"], check=True)
302
- _call_subprocess(["dpkg", "-i", str(deb_path)], check=True)
303
- finally:
304
- shutil.rmtree(download_dir, ignore_errors=True)
305
-
306
-
307
322
  def _verify_connectivity(timeout: float = 5.0) -> bool:
308
323
  try:
309
324
  _call_subprocess(["/bin/ping", "-c", "2", "1.1.1.1"], check=True, timeout=timeout)
@@ -380,7 +395,6 @@ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str
380
395
  "cpu_share": cpu_share,
381
396
  "created_at": record.get("created_at"),
382
397
  "status": status,
383
- "tunnel": record.get("tunnel"),
384
398
  }
385
399
  )
386
400
 
@@ -618,89 +632,6 @@ def _remove_container_record(vmid: int) -> None:
618
632
  _invalidate_managed_containers_cache()
619
633
 
620
634
 
621
- def _update_container_tunnel(vmid: int, tunnel: Optional[Dict[str, Any]]) -> None:
622
- record = _read_container_record(vmid)
623
- if tunnel:
624
- record["tunnel"] = tunnel
625
- else:
626
- record.pop("tunnel", None)
627
- _write_container_record(vmid, record)
628
-
629
-
630
- def _ensure_cloudflare_token(config: Dict[str, Any]) -> str:
631
- cloudflare = config.get("cloudflare") or {}
632
- token = cloudflare.get("api_token")
633
- if not token:
634
- raise RuntimeError("Cloudflare API token is not configured.")
635
- return token
636
-
637
-
638
- def _launch_container_tunnel(proxmox: Any, node: str, vmid: int, tunnel: Dict[str, Any]) -> Dict[str, Any]:
639
- port = int(tunnel.get("container_port") or 0)
640
- if not port:
641
- raise ValueError("container_port is required to create a tunnel.")
642
- requested_hostname = tunnel.get("url") or None
643
- protocol = (tunnel.get("protocol") or "http").lower()
644
- ip_address = _resolve_container_ip(proxmox, node, vmid)
645
- target_url = f"{protocol}://{ip_address}:{port}"
646
- name = tunnel.get("name") or _build_tunnel_name(vmid, port)
647
- _stop_cloudflare_process(name)
648
- proc, assigned_url = _start_cloudflare_process(name, target_url, hostname=requested_hostname)
649
- if not assigned_url:
650
- raise RuntimeError("Failed to determine Cloudflare hostname for the tunnel.")
651
- updated = {
652
- "name": name,
653
- "container_port": port,
654
- "url": assigned_url,
655
- "protocol": protocol,
656
- "status": "running",
657
- "pid": proc.pid,
658
- "target_ip": ip_address,
659
- "target_url": target_url,
660
- "last_updated": datetime.utcnow().isoformat() + "Z",
661
- }
662
- _update_container_tunnel(vmid, updated)
663
- return updated
664
-
665
-
666
- def _stop_container_tunnel(vmid: int) -> None:
667
- try:
668
- record = _read_container_record(vmid)
669
- except FileNotFoundError:
670
- return
671
- tunnel = record.get("tunnel")
672
- if not tunnel:
673
- return
674
- name = tunnel.get("name") or _build_tunnel_name(vmid, int(tunnel.get("container_port") or 0))
675
- stopped = _stop_cloudflare_process(name)
676
- if not stopped and tunnel.get("status") == "stopped":
677
- return
678
- tunnel_update = {
679
- **tunnel,
680
- "status": "stopped",
681
- "pid": None,
682
- "last_updated": datetime.utcnow().isoformat() + "Z",
683
- }
684
- _update_container_tunnel(vmid, tunnel_update)
685
-
686
-
687
- def _remove_container_tunnel_state(vmid: int) -> None:
688
- _stop_container_tunnel(vmid)
689
- _update_container_tunnel(vmid, None)
690
-
691
-
692
- def _ensure_container_tunnel_running(proxmox: Any, node: str, vmid: int) -> None:
693
- try:
694
- record = _read_container_record(vmid)
695
- except FileNotFoundError:
696
- return
697
- tunnel = record.get("tunnel")
698
- if not tunnel:
699
- return
700
- _ensure_cloudflare_token(_load_config())
701
- _launch_container_tunnel(proxmox, node, vmid, tunnel)
702
-
703
-
704
635
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
705
636
  templates = config.get("templates") or []
706
637
  default_template = templates[0] if templates else ""
@@ -816,116 +747,6 @@ def _run_pct_push(vmid: int, src: str, dest: str) -> subprocess.CompletedProcess
816
747
  return _call_subprocess(["pct", "push", str(vmid), src, dest])
817
748
 
818
749
 
819
- def _build_tunnel_name(vmid: int, port: int) -> str:
820
- return f"portacode-ct{vmid}-{port}"
821
-
822
-
823
- def _get_cloudflared_binary() -> str:
824
- binary = shutil.which("cloudflared")
825
- if not binary:
826
- raise RuntimeError(
827
- "cloudflared is required for Cloudflare tunnels but was not found on PATH. "
828
- "Install cloudflared and run 'cloudflared tunnel login' before creating tunnels."
829
- )
830
- return binary
831
-
832
-
833
- def _drain_stream(stream: Optional[Any]) -> None:
834
- if stream is None:
835
- return
836
- try:
837
- for _ in iter(stream.readline, ""):
838
- continue
839
- except Exception:
840
- pass
841
- finally:
842
- try:
843
- stream.close()
844
- except Exception:
845
- pass
846
-
847
-
848
- def _await_quick_tunnel_url(proc: subprocess.Popen, timeout: float = 15.0) -> Optional[str]:
849
- if not proc.stdout:
850
- return None
851
- cf_re = re.compile(r"https://[A-Za-z0-9\-.]+\.cfargotunnel\.com")
852
- deadline = time.time() + timeout
853
- while time.time() < deadline:
854
- ready, _, _ = select.select([proc.stdout], [], [], 1)
855
- if not ready:
856
- continue
857
- line = proc.stdout.readline()
858
- if not line:
859
- continue
860
- match = cf_re.search(line)
861
- if match:
862
- return match.group(0)
863
- return None
864
-
865
-
866
- def _start_cloudflare_process(name: str, target_url: str, hostname: Optional[str] = None) -> Tuple[subprocess.Popen, Optional[str]]:
867
- binary = _get_cloudflared_binary()
868
- cmd = [
869
- binary,
870
- "tunnel",
871
- "--url",
872
- target_url,
873
- "--no-autoupdate",
874
- ]
875
- if hostname:
876
- cmd.extend(["--hostname", hostname])
877
- stdout_target = subprocess.DEVNULL
878
- else:
879
- stdout_target = subprocess.PIPE
880
- proc = subprocess.Popen(
881
- cmd,
882
- stdout=stdout_target,
883
- stderr=subprocess.PIPE,
884
- text=True,
885
- )
886
- with _CLOUDFLARE_TUNNELS_LOCK:
887
- _CLOUDFLARE_TUNNEL_PROCESSES[name] = proc
888
- assigned_url = hostname
889
- if not hostname:
890
- assigned_url = _await_quick_tunnel_url(proc)
891
- threading.Thread(target=_drain_stream, args=(proc.stdout,), daemon=True).start()
892
- threading.Thread(target=_drain_stream, args=(proc.stderr,), daemon=True).start()
893
- return proc, assigned_url
894
-
895
-
896
- def _stop_cloudflare_process(name: str) -> bool:
897
- with _CLOUDFLARE_TUNNELS_LOCK:
898
- proc = _CLOUDFLARE_TUNNEL_PROCESSES.pop(name, None)
899
- if not proc:
900
- return False
901
- try:
902
- proc.terminate()
903
- proc.wait(timeout=5)
904
- except subprocess.TimeoutExpired:
905
- proc.kill()
906
- proc.wait()
907
- return True
908
-
909
-
910
- def _resolve_container_ip(proxmox: Any, node: str, vmid: int) -> str:
911
- status = proxmox.nodes(node).lxc(vmid).status.current.get()
912
- if status:
913
- ip_field = status.get("ip")
914
- if isinstance(ip_field, list):
915
- for entry in ip_field:
916
- if isinstance(entry, str) and "." in entry:
917
- return entry.split("/")[0]
918
- elif isinstance(ip_field, str) and "." in ip_field:
919
- return ip_field.split("/")[0]
920
- res = _run_pct_exec(vmid, ["ip", "-4", "-o", "addr", "show", "eth0"])
921
- line = res.stdout.splitlines()[0] if res.stdout else ""
922
- parts = line.split()
923
- if len(parts) >= 4:
924
- addr = parts[3]
925
- return addr.split("/")[0]
926
- raise RuntimeError("Unable to determine container IP address")
927
-
928
-
929
750
  def _push_bytes_to_container(
930
751
  vmid: int, user: str, path: str, data: bytes, mode: int = 0o600
931
752
  ) -> None:
@@ -1224,12 +1045,6 @@ def _bootstrap_portacode(
1224
1045
  return public_key, results
1225
1046
 
1226
1047
 
1227
- def _build_cloudflare_snapshot(cloudflare_config: Dict[str, Any] | None) -> Dict[str, Any]:
1228
- if not cloudflare_config:
1229
- return {"configured": False}
1230
- return {"configured": bool(cloudflare_config.get("api_token"))}
1231
-
1232
-
1233
1048
  def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
1234
1049
  network = config.get("network", {})
1235
1050
  base_network = {
@@ -1237,13 +1052,9 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
1237
1052
  "message": network.get("message"),
1238
1053
  "bridge": network.get("bridge", DEFAULT_BRIDGE),
1239
1054
  }
1240
- cloudflare_snapshot = _build_cloudflare_snapshot(config.get("cloudflare"))
1241
1055
  if not config:
1242
- return {
1243
- "configured": False,
1244
- "network": base_network,
1245
- "cloudflare": cloudflare_snapshot,
1246
- }
1056
+ return {"configured": False, "network": base_network}
1057
+ _ensure_templates_refreshed_on_startup(config)
1247
1058
  return {
1248
1059
  "configured": True,
1249
1060
  "host": config.get("host"),
@@ -1254,54 +1065,18 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
1254
1065
  "templates": config.get("templates") or [],
1255
1066
  "last_verified": config.get("last_verified"),
1256
1067
  "network": base_network,
1257
- "cloudflare": cloudflare_snapshot,
1258
1068
  }
1259
1069
 
1260
1070
 
1261
- def _resolve_proxmox_credentials(
1262
- token_identifier: Optional[str],
1263
- token_value: Optional[str],
1264
- existing: Dict[str, Any],
1265
- ) -> Tuple[str, str, str]:
1266
- if token_identifier:
1267
- if not token_value:
1268
- raise ValueError("token_value is required when providing a new token_identifier")
1269
- user, token_name = _parse_token(token_identifier)
1270
- return user, token_name, token_value
1271
- if existing and existing.get("user") and existing.get("token_name") and existing.get("token_value"):
1272
- return existing["user"], existing["token_name"], existing["token_value"]
1273
- raise ValueError("Proxmox token identifier and value are required when no existing configuration is available")
1274
-
1275
-
1276
- def _build_cloudflare_config(existing: Dict[str, Any], api_token: Optional[str]) -> Dict[str, Any]:
1277
- cloudflare: Dict[str, Any] = dict(existing.get("cloudflare", {}) or {})
1278
- if api_token:
1279
- cloudflare["api_token"] = api_token
1280
- if cloudflare.get("api_token"):
1281
- cloudflare["configured"] = True
1282
- elif "configured" in cloudflare:
1283
- cloudflare.pop("configured", None)
1284
- return cloudflare
1285
-
1286
-
1287
- def configure_infrastructure(
1288
- token_identifier: Optional[str] = None,
1289
- token_value: Optional[str] = None,
1290
- verify_ssl: Optional[bool] = None,
1291
- cloudflare_api_token: Optional[str] = None,
1292
- ) -> Dict[str, Any]:
1071
+ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl: bool = False) -> Dict[str, Any]:
1293
1072
  ProxmoxAPI = _ensure_proxmoxer()
1294
- existing = _load_config()
1295
- user, token_name, resolved_token_value = _resolve_proxmox_credentials(
1296
- token_identifier, token_value, existing
1297
- )
1298
- actual_verify_ssl = verify_ssl if verify_ssl is not None else existing.get("verify_ssl", False)
1073
+ user, token_name = _parse_token(token_identifier)
1299
1074
  client = ProxmoxAPI(
1300
1075
  DEFAULT_HOST,
1301
1076
  user=user,
1302
1077
  token_name=token_name,
1303
- token_value=resolved_token_value,
1304
- verify_ssl=actual_verify_ssl,
1078
+ token_value=token_value,
1079
+ verify_ssl=verify_ssl,
1305
1080
  timeout=30,
1306
1081
  )
1307
1082
  node = _pick_node(client)
@@ -1309,36 +1084,31 @@ def configure_infrastructure(
1309
1084
  storages = client.nodes(node).storage.get()
1310
1085
  default_storage = _pick_storage(storages)
1311
1086
  templates = _list_templates(client, node, storages)
1312
- network = dict(existing.get("network", {}) or {})
1313
- _ensure_cloudflared_installed()
1314
- if not network.get("applied"):
1315
- try:
1316
- network = _ensure_bridge()
1317
- # Wait for network convergence before validating connectivity
1318
- time.sleep(2)
1319
- if not _verify_connectivity():
1320
- raise RuntimeError("Connectivity check failed; bridge reverted")
1321
- network["health"] = "healthy"
1322
- except Exception as exc:
1323
- logger.warning("Bridge setup failed; reverting previous changes: %s", exc)
1324
- _revert_bridge()
1325
- raise
1087
+ network: Dict[str, Any] = {}
1088
+ try:
1089
+ network = _ensure_bridge()
1090
+ # Wait for network convergence before validating connectivity
1091
+ time.sleep(2)
1092
+ if not _verify_connectivity():
1093
+ raise RuntimeError("Connectivity check failed; bridge reverted")
1094
+ network["health"] = "healthy"
1095
+ except Exception as exc:
1096
+ logger.warning("Bridge setup failed; reverting previous changes: %s", exc)
1097
+ _revert_bridge()
1098
+ raise
1326
1099
  config = {
1327
1100
  "host": DEFAULT_HOST,
1328
1101
  "node": node,
1329
1102
  "user": user,
1330
1103
  "token_name": token_name,
1331
- "token_value": resolved_token_value,
1332
- "verify_ssl": actual_verify_ssl,
1104
+ "token_value": token_value,
1105
+ "verify_ssl": verify_ssl,
1333
1106
  "default_storage": default_storage,
1334
1107
  "templates": templates,
1335
1108
  "last_verified": datetime.utcnow().isoformat() + "Z",
1336
1109
  "network": network,
1337
1110
  "node_status": status,
1338
1111
  }
1339
- cloudflare = _build_cloudflare_config(existing, cloudflare_api_token)
1340
- if cloudflare:
1341
- config["cloudflare"] = cloudflare
1342
1112
  _save_config(config)
1343
1113
  snapshot = build_snapshot(config)
1344
1114
  snapshot["node_status"] = status
@@ -1389,7 +1159,7 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
1389
1159
  memory=int(payload["ram_mib"]),
1390
1160
  swap=int(payload.get("swap_mb", 0)),
1391
1161
  cores=max(int(payload.get("cores", 1)), 1),
1392
- cpuunits=int(payload.get("cpuunits", 256)),
1162
+ cpulimit=float(payload.get("cpulimit", payload.get("cpus", 1))),
1393
1163
  net0=payload["net0"],
1394
1164
  unprivileged=int(payload.get("unprivileged", 1)),
1395
1165
  description=payload.get("description", MANAGED_MARKER),
@@ -1412,7 +1182,10 @@ class CreateProxmoxContainerHandler(SyncHandler):
1412
1182
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1413
1183
  logger.info("create_proxmox_container command received")
1414
1184
  request_id = message.get("request_id")
1415
- device_id = message.get("device_id")
1185
+ raw_device_id = message.get("device_id")
1186
+ device_id = str(raw_device_id or "").strip()
1187
+ if not device_id:
1188
+ raise ValueError("device_id is required to create a container")
1416
1189
  device_public_key = (message.get("device_public_key") or "").strip()
1417
1190
  device_private_key = (message.get("device_private_key") or "").strip()
1418
1191
  has_device_keypair = bool(device_public_key and device_private_key)
@@ -1444,6 +1217,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1444
1217
  message=start_message,
1445
1218
  phase="lifecycle",
1446
1219
  request_id=request_id,
1220
+ on_behalf_of_device=device_id,
1447
1221
  )
1448
1222
  try:
1449
1223
  result = action()
@@ -1459,6 +1233,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1459
1233
  phase="lifecycle",
1460
1234
  request_id=request_id,
1461
1235
  details={"error": str(exc)},
1236
+ on_behalf_of_device=device_id,
1462
1237
  )
1463
1238
  raise
1464
1239
  _emit_progress_event(
@@ -1471,6 +1246,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1471
1246
  message=success_message,
1472
1247
  phase="lifecycle",
1473
1248
  request_id=request_id,
1249
+ on_behalf_of_device=device_id,
1474
1250
  )
1475
1251
  current_step_index += 1
1476
1252
  return result
@@ -1497,7 +1273,8 @@ class CreateProxmoxContainerHandler(SyncHandler):
1497
1273
  proxmox = _connect_proxmox(config)
1498
1274
  node = config.get("node") or DEFAULT_NODE_NAME
1499
1275
  payload = _build_container_payload(message, config)
1500
- payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
1276
+ payload["cpulimit"] = float(payload["cpus"])
1277
+ payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
1501
1278
  payload["memory"] = int(payload["ram_mib"])
1502
1279
  payload["node"] = node
1503
1280
  logger.debug(
@@ -1512,6 +1289,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1512
1289
  payload["vmid"] = vmid
1513
1290
  payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1514
1291
  payload["status"] = "creating"
1292
+ payload["device_id"] = device_id
1515
1293
  _write_container_record(vmid, payload)
1516
1294
  return proxmox, node, vmid, payload
1517
1295
 
@@ -1572,6 +1350,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1572
1350
  phase="bootstrap",
1573
1351
  request_id=request_id,
1574
1352
  details=details or None,
1353
+ on_behalf_of_device=device_id,
1575
1354
  )
1576
1355
 
1577
1356
  public_key, steps = _bootstrap_portacode(
@@ -1615,6 +1394,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1615
1394
  message="Notifying the server of the new device…",
1616
1395
  phase="service",
1617
1396
  request_id=request_id,
1397
+ on_behalf_of_device=device_id,
1618
1398
  )
1619
1399
  _emit_progress_event(
1620
1400
  self,
@@ -1626,6 +1406,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1626
1406
  message="Authentication metadata recorded.",
1627
1407
  phase="service",
1628
1408
  request_id=request_id,
1409
+ on_behalf_of_device=device_id,
1629
1410
  )
1630
1411
 
1631
1412
  install_step = service_start_index + 1
@@ -1640,6 +1421,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1640
1421
  message="Running sudo portacode service install…",
1641
1422
  phase="service",
1642
1423
  request_id=request_id,
1424
+ on_behalf_of_device=device_id,
1643
1425
  )
1644
1426
 
1645
1427
  cmd = f"su - {payload['username']} -c 'sudo -S portacode service install'"
@@ -1660,6 +1442,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1660
1442
  "stderr": res.get("stderr"),
1661
1443
  "stdout": res.get("stdout"),
1662
1444
  },
1445
+ on_behalf_of_device=device_id,
1663
1446
  )
1664
1447
  raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1665
1448
 
@@ -1673,6 +1456,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1673
1456
  message="Portacode service install finished.",
1674
1457
  phase="service",
1675
1458
  request_id=request_id,
1459
+ on_behalf_of_device=device_id,
1676
1460
  )
1677
1461
 
1678
1462
  logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
@@ -1696,6 +1480,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1696
1480
  },
1697
1481
  "setup_steps": steps,
1698
1482
  "device_id": device_id,
1483
+ "on_behalf_of_device": device_id,
1699
1484
  "service_installed": service_installed,
1700
1485
  }
1701
1486
 
@@ -1721,6 +1506,9 @@ class StartPortacodeServiceHandler(SyncHandler):
1721
1506
  password = record.get("password")
1722
1507
  if not user or not password:
1723
1508
  raise RuntimeError("Container credentials unavailable")
1509
+ on_behalf_of_device = record.get("device_id")
1510
+ if on_behalf_of_device:
1511
+ on_behalf_of_device = str(on_behalf_of_device)
1724
1512
 
1725
1513
  start_index = int(message.get("step_index", 1))
1726
1514
  total_steps = int(message.get("total_steps", start_index + 2))
@@ -1738,6 +1526,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1738
1526
  message="Notifying the server of the new device…",
1739
1527
  phase="service",
1740
1528
  request_id=request_id,
1529
+ on_behalf_of_device=on_behalf_of_device,
1741
1530
  )
1742
1531
  _emit_progress_event(
1743
1532
  self,
@@ -1749,6 +1538,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1749
1538
  message="Authentication metadata recorded.",
1750
1539
  phase="service",
1751
1540
  request_id=request_id,
1541
+ on_behalf_of_device=on_behalf_of_device,
1752
1542
  )
1753
1543
 
1754
1544
  install_step = start_index + 1
@@ -1763,6 +1553,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1763
1553
  message="Running sudo portacode service install…",
1764
1554
  phase="service",
1765
1555
  request_id=request_id,
1556
+ on_behalf_of_device=on_behalf_of_device,
1766
1557
  )
1767
1558
 
1768
1559
  cmd = f"su - {user} -c 'sudo -S portacode service install'"
@@ -1783,6 +1574,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1783
1574
  "stderr": res.get("stderr"),
1784
1575
  "stdout": res.get("stdout"),
1785
1576
  },
1577
+ on_behalf_of_device=on_behalf_of_device,
1786
1578
  )
1787
1579
  raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1788
1580
 
@@ -1796,6 +1588,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1796
1588
  message="Portacode service install finished.",
1797
1589
  phase="service",
1798
1590
  request_id=request_id,
1591
+ on_behalf_of_device=on_behalf_of_device,
1799
1592
  )
1800
1593
 
1801
1594
  return {
@@ -1822,10 +1615,6 @@ class StartProxmoxContainerHandler(SyncHandler):
1822
1615
 
1823
1616
  status, elapsed = _start_container(proxmox, node, vmid)
1824
1617
  _update_container_record(vmid, {"status": "running"})
1825
- try:
1826
- _ensure_container_tunnel_running(proxmox, node, vmid)
1827
- except Exception as exc:
1828
- raise RuntimeError(f"Failed to start Cloudflare tunnel for container {vmid}: {exc}") from exc
1829
1618
 
1830
1619
  infra = get_infra_snapshot()
1831
1620
  return {
@@ -1855,7 +1644,6 @@ class StopProxmoxContainerHandler(SyncHandler):
1855
1644
  _ensure_container_managed(proxmox, node, vmid)
1856
1645
 
1857
1646
  status, elapsed = _stop_container(proxmox, node, vmid)
1858
- _stop_container_tunnel(vmid)
1859
1647
  final_status = status.get("status") or "stopped"
1860
1648
  _update_container_record(vmid, {"status": final_status})
1861
1649
 
@@ -1891,13 +1679,8 @@ class RemoveProxmoxContainerHandler(SyncHandler):
1891
1679
  node = _get_node_from_config(config)
1892
1680
  _ensure_container_managed(proxmox, node, vmid)
1893
1681
 
1894
- _stop_container_tunnel(vmid)
1895
1682
  stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
1896
1683
  delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
1897
- try:
1898
- _update_container_tunnel(vmid, None)
1899
- except FileNotFoundError:
1900
- pass
1901
1684
  _remove_container_record(vmid)
1902
1685
 
1903
1686
  infra = get_infra_snapshot()
@@ -1916,134 +1699,6 @@ class RemoveProxmoxContainerHandler(SyncHandler):
1916
1699
  }
1917
1700
 
1918
1701
 
1919
- class CreateCloudflareTunnelHandler(SyncHandler):
1920
- """Create a Cloudflare tunnel for a container."""
1921
-
1922
- @property
1923
- def command_name(self) -> str:
1924
- return "create_cloudflare_tunnel"
1925
-
1926
- def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1927
- vmid = _parse_ctid(message)
1928
- container_port = int(message.get("container_port") or 0)
1929
- if container_port <= 0:
1930
- raise ValueError("container_port is required and must be greater than zero.")
1931
- hostname = (message.get("cloudflare_url") or message.get("hostname") or "").strip()
1932
- hostname = hostname or None
1933
- protocol = (message.get("protocol") or "http").strip().lower()
1934
- if protocol not in {"http", "https", "tcp"}:
1935
- raise ValueError("protocol must be one of http, https, or tcp.")
1936
- config = _ensure_infra_configured()
1937
- _ensure_cloudflare_token(config)
1938
- proxmox = _connect_proxmox(config)
1939
- node = _get_node_from_config(config)
1940
- _ensure_container_managed(proxmox, node, vmid)
1941
- status = proxmox.nodes(node).lxc(vmid).status.current.get().get("status")
1942
- if status != "running":
1943
- raise RuntimeError("Container must be running to create a tunnel.")
1944
- tunnel = {
1945
- "container_port": container_port,
1946
- "protocol": protocol,
1947
- }
1948
- if hostname:
1949
- tunnel["url"] = hostname
1950
- created = _launch_container_tunnel(proxmox, node, vmid, tunnel)
1951
- infra = get_infra_snapshot()
1952
- host_url = created.get("url")
1953
- response_message = f"Created Cloudflare tunnel for container {vmid}."
1954
- if host_url:
1955
- response_message = f"{response_message[:-1]} -> {host_url}."
1956
- response = {
1957
- "event": "cloudflare_tunnel_created",
1958
- "ctid": str(vmid),
1959
- "success": True,
1960
- "message": response_message,
1961
- "tunnel": created,
1962
- "infra": infra,
1963
- }
1964
- device_id = message.get("device_id")
1965
- if device_id:
1966
- response["device_id"] = device_id
1967
- return response
1968
-
1969
-
1970
- class UpdateCloudflareTunnelHandler(SyncHandler):
1971
- """Update an existing Cloudflare tunnel for a container."""
1972
-
1973
- @property
1974
- def command_name(self) -> str:
1975
- return "update_cloudflare_tunnel"
1976
-
1977
- def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1978
- vmid = _parse_ctid(message)
1979
- config = _ensure_infra_configured()
1980
- _ensure_cloudflare_token(config)
1981
- proxmox = _connect_proxmox(config)
1982
- node = _get_node_from_config(config)
1983
- _ensure_container_managed(proxmox, node, vmid)
1984
- record = _read_container_record(vmid)
1985
- tunnel = record.get("tunnel")
1986
- if not tunnel:
1987
- raise RuntimeError("No Cloudflare tunnel configured for this container.")
1988
- container_port = int(message.get("container_port") or tunnel.get("container_port") or 0)
1989
- if container_port <= 0:
1990
- raise ValueError("container_port must be greater than zero.")
1991
- hostname = (message.get("cloudflare_url") or tunnel.get("url") or "").strip()
1992
- hostname = hostname or None
1993
- protocol = (message.get("protocol") or tunnel.get("protocol") or "http").strip().lower()
1994
- if protocol not in {"http", "https", "tcp"}:
1995
- raise ValueError("protocol must be one of http, https, or tcp.")
1996
- updated_tunnel = {
1997
- "container_port": container_port,
1998
- "protocol": protocol,
1999
- }
2000
- if hostname:
2001
- updated_tunnel["url"] = hostname
2002
- result = _launch_container_tunnel(proxmox, node, vmid, updated_tunnel)
2003
- infra = get_infra_snapshot()
2004
- host_url = result.get("url")
2005
- response_message = f"Updated Cloudflare tunnel for container {vmid}."
2006
- if host_url:
2007
- response_message = f"{response_message[:-1]} -> {host_url}."
2008
- response = {
2009
- "event": "cloudflare_tunnel_updated",
2010
- "ctid": str(vmid),
2011
- "success": True,
2012
- "message": response_message,
2013
- "tunnel": result,
2014
- "infra": infra,
2015
- }
2016
- device_id = message.get("device_id")
2017
- if device_id:
2018
- response["device_id"] = device_id
2019
- return response
2020
-
2021
-
2022
- class RemoveCloudflareTunnelHandler(SyncHandler):
2023
- """Remove any Cloudflare tunnel associated with a container."""
2024
-
2025
- @property
2026
- def command_name(self) -> str:
2027
- return "remove_cloudflare_tunnel"
2028
-
2029
- def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
2030
- vmid = _parse_ctid(message)
2031
- _remove_container_tunnel_state(vmid)
2032
- infra = get_infra_snapshot()
2033
- response = {
2034
- "event": "cloudflare_tunnel_removed",
2035
- "ctid": str(vmid),
2036
- "success": True,
2037
- "message": f"Removed Cloudflare tunnel state for container {vmid}.",
2038
- "tunnel": None,
2039
- "infra": infra,
2040
- }
2041
- device_id = message.get("device_id")
2042
- if device_id:
2043
- response["device_id"] = device_id
2044
- return response
2045
-
2046
-
2047
1702
  class ConfigureProxmoxInfraHandler(SyncHandler):
2048
1703
  @property
2049
1704
  def command_name(self) -> str:
@@ -2052,13 +1707,10 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
2052
1707
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
2053
1708
  token_identifier = message.get("token_identifier")
2054
1709
  token_value = message.get("token_value")
2055
- verify_ssl = message.get("verify_ssl")
2056
- snapshot = configure_infrastructure(
2057
- token_identifier=token_identifier,
2058
- token_value=token_value,
2059
- verify_ssl=verify_ssl,
2060
- cloudflare_api_token=message.get("cloudflare_api_token"),
2061
- )
1710
+ verify_ssl = bool(message.get("verify_ssl"))
1711
+ if not token_identifier or not token_value:
1712
+ raise ValueError("token_identifier and token_value are required")
1713
+ snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
2062
1714
  return {
2063
1715
  "event": "proxmox_infra_configured",
2064
1716
  "success": True,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.15.dev3
3
+ Version: 1.4.15.dev10
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,7 +1,7 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=1sfx8JK_QXWbIlvcX2Sn3XEnIw9YLhiiBq9EMvNgnss,719
4
+ portacode/_version.py,sha256=OsU3LmSVwSb16YuRZZoOGhx4fmAppA2W-vOQo60j6hQ,721
5
5
  portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
@@ -14,15 +14,15 @@ portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5wei
14
14
  portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
15
15
  portacode/connection/terminal.py,sha256=oyLPOVLPlUuN_eRvHPGazB51yi8W8JEF3oOEYxucGTE,45069
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=5VZSDPvXjtOl-8YZ-2UVtH8WgPnEWe7WB33YPrS8_8Q,104190
18
- portacode/connection/handlers/__init__.py,sha256=j69jGkf2-mYyCicvYfp2wk8-xB8yqpWktiN5xADXBno,3137
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=OtObmoVAXlf0uU1HidTWNmyJYBS1Yl6Rpgyh6TOTjUQ,100590
18
+ portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
19
19
  portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
20
20
  portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
21
21
  portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZbQw6sF04M3dM6rUV8Q,24477
22
22
  portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
23
23
  portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
24
24
  portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
25
- portacode/connection/handlers/proxmox_infra.py,sha256=mH6xxT9v9X0jwdCBjU3p48lURYisY0T0HPjuzV3eoH8,76070
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=5DAHhzbegCI0fKdekP8Or6jrGZptmthLdq-RMtOPZDc,63843
26
26
  portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
27
27
  portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
28
28
  portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
65
65
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
66
66
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
67
67
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
68
- portacode-1.4.15.dev3.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.15.dev10.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
69
69
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
70
70
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
71
71
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
91
91
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
92
92
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
93
93
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
94
- portacode-1.4.15.dev3.dist-info/METADATA,sha256=fhua1K0pTVsxZaun0VkgDDpUBh5AdovF_z9U6_LyktE,13051
95
- portacode-1.4.15.dev3.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
96
- portacode-1.4.15.dev3.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
- portacode-1.4.15.dev3.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
- portacode-1.4.15.dev3.dist-info/RECORD,,
94
+ portacode-1.4.15.dev10.dist-info/METADATA,sha256=FZkeltpJrx27P2fnujDDrYsinIFjnm1VsnnqTSVBk1g,13052
95
+ portacode-1.4.15.dev10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.15.dev10.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.15.dev10.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.15.dev10.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5