portacode 1.4.15.dev3__py3-none-any.whl → 1.4.15.dev15__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.
@@ -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,8 +16,7 @@ import sys
17
16
  import tempfile
18
17
  import time
19
18
  import threading
20
- import urllib.request
21
- from datetime import datetime
19
+ from datetime import datetime, timezone
22
20
  from pathlib import Path
23
21
  from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
24
22
 
@@ -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
+ TEMPLATES_REFRESH_INTERVAL_S = 300
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,64 @@ 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 _current_time_iso() -> str:
201
+ return datetime.now(timezone.utc).isoformat()
202
+
203
+
204
+ def _parse_iso_timestamp(value: str) -> Optional[datetime]:
205
+ if not value:
206
+ return None
207
+ text = value
208
+ if text.endswith("Z"):
209
+ text = text[:-1] + "+00:00"
210
+ try:
211
+ return datetime.fromisoformat(text)
212
+ except ValueError:
213
+ return None
214
+
215
+
216
+ def _templates_need_refresh(config: Dict[str, Any]) -> bool:
217
+ if not config or not config.get("token_value"):
218
+ return False
219
+ last = _parse_iso_timestamp(config.get("templates_last_refreshed") or "")
220
+ if not last:
221
+ return True
222
+ return (datetime.now(timezone.utc) - last).total_seconds() >= TEMPLATES_REFRESH_INTERVAL_S
223
+
224
+
225
+ def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
226
+ if not _templates_need_refresh(config):
227
+ return
228
+ try:
229
+ client = _build_proxmox_client_from_config(config)
230
+ node = config.get("node") or _pick_node(client)
231
+ storages = client.nodes(node).storage.get()
232
+ templates = _list_templates(client, node, storages)
233
+ if templates:
234
+ config["templates"] = templates
235
+ config["templates_last_refreshed"] = _current_time_iso()
236
+ _save_config(config)
237
+ except Exception as exc:
238
+ logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
239
+
240
+
184
241
  def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
185
242
  candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
186
243
  if not candidates:
@@ -285,25 +342,6 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
285
342
  return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
286
343
 
287
344
 
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
345
  def _verify_connectivity(timeout: float = 5.0) -> bool:
308
346
  try:
309
347
  _call_subprocess(["/bin/ping", "-c", "2", "1.1.1.1"], check=True, timeout=timeout)
@@ -380,7 +418,6 @@ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str
380
418
  "cpu_share": cpu_share,
381
419
  "created_at": record.get("created_at"),
382
420
  "status": status,
383
- "tunnel": record.get("tunnel"),
384
421
  }
385
422
  )
386
423
 
@@ -459,48 +496,119 @@ def _friendly_step_label(step_name: str) -> str:
459
496
  return normalized.capitalize()
460
497
 
461
498
 
499
+ _PACKAGE_MANAGER_PROFILES: Dict[str, Dict[str, Any]] = {
500
+ "apt": {
501
+ "update_cmd": "apt-get update -y",
502
+ "update_step_name": "apt_update",
503
+ "install_cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
504
+ "install_step_name": "install_deps",
505
+ "update_retries": 4,
506
+ "install_retries": 5,
507
+ },
508
+ "dnf": {
509
+ "update_cmd": "dnf check-update || true",
510
+ "update_step_name": "dnf_update",
511
+ "install_cmd": "dnf install -y python3 python3-pip sudo",
512
+ "install_step_name": "install_deps",
513
+ "update_retries": 3,
514
+ "install_retries": 5,
515
+ },
516
+ "yum": {
517
+ "update_cmd": "yum makecache",
518
+ "update_step_name": "yum_update",
519
+ "install_cmd": "yum install -y python3 python3-pip sudo",
520
+ "install_step_name": "install_deps",
521
+ "update_retries": 3,
522
+ "install_retries": 5,
523
+ },
524
+ "apk": {
525
+ "update_cmd": "apk update",
526
+ "update_step_name": "apk_update",
527
+ "install_cmd": "apk add --no-cache python3 py3-pip sudo",
528
+ "install_step_name": "install_deps",
529
+ "update_retries": 3,
530
+ "install_retries": 5,
531
+ },
532
+ "pacman": {
533
+ "update_cmd": "pacman -Sy --noconfirm",
534
+ "update_step_name": "pacman_update",
535
+ "install_cmd": "pacman -S --noconfirm python python-pip sudo",
536
+ "install_step_name": "install_deps",
537
+ "update_retries": 3,
538
+ "install_retries": 5,
539
+ },
540
+ "zypper": {
541
+ "update_cmd": "zypper refresh",
542
+ "update_step_name": "zypper_update",
543
+ "install_cmd": "zypper install -y python3 python3-pip sudo",
544
+ "install_step_name": "install_deps",
545
+ "update_retries": 3,
546
+ "install_retries": 5,
547
+ },
548
+ }
549
+
550
+ _UPDATE_RETRY_ON = [
551
+ "Temporary failure resolving",
552
+ "Could not resolve",
553
+ "Failed to fetch",
554
+ ]
555
+
556
+ _INSTALL_RETRY_ON = [
557
+ "lock-frontend",
558
+ "Unable to acquire the dpkg frontend lock",
559
+ "Temporary failure resolving",
560
+ "Could not resolve",
561
+ "Failed to fetch",
562
+ ]
563
+
564
+
462
565
  def _build_bootstrap_steps(
463
566
  user: str,
464
567
  password: str,
465
568
  ssh_key: str,
466
569
  include_portacode_connect: bool = True,
570
+ package_manager: str = "apt",
467
571
  ) -> List[Dict[str, Any]]:
468
- steps = [
469
- {
470
- "name": "apt_update",
471
- "cmd": "apt-get update -y",
472
- "retries": 4,
473
- "retry_delay_s": 5,
474
- "retry_on": [
475
- "Temporary failure resolving",
476
- "Could not resolve",
477
- "Failed to fetch",
478
- ],
479
- },
480
- {
481
- "name": "install_deps",
482
- "cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
483
- "retries": 5,
484
- "retry_delay_s": 5,
485
- "retry_on": [
486
- "lock-frontend",
487
- "Unable to acquire the dpkg frontend lock",
488
- "Temporary failure resolving",
489
- "Could not resolve",
490
- "Failed to fetch",
491
- ],
492
- },
493
- {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
494
- {"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
495
- ]
572
+ profile = _PACKAGE_MANAGER_PROFILES.get(package_manager, _PACKAGE_MANAGER_PROFILES["apt"])
573
+ steps: List[Dict[str, Any]] = []
574
+ update_cmd = profile.get("update_cmd")
575
+ if update_cmd:
576
+ steps.append(
577
+ {
578
+ "name": profile.get("update_step_name", "package_update"),
579
+ "cmd": update_cmd,
580
+ "retries": profile.get("update_retries", 3),
581
+ "retry_delay_s": 5,
582
+ "retry_on": _UPDATE_RETRY_ON,
583
+ }
584
+ )
585
+ install_cmd = profile.get("install_cmd")
586
+ if install_cmd:
587
+ steps.append(
588
+ {
589
+ "name": profile.get("install_step_name", "install_deps"),
590
+ "cmd": install_cmd,
591
+ "retries": profile.get("install_retries", 5),
592
+ "retry_delay_s": 5,
593
+ "retry_on": _INSTALL_RETRY_ON,
594
+ }
595
+ )
596
+ steps.extend(
597
+ [
598
+ {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
599
+ {"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
600
+ ]
601
+ )
496
602
  if password:
497
603
  steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
498
604
  if ssh_key:
499
- steps.append({
500
- "name": "add_ssh_key",
501
- "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
502
- "retries": 0,
503
- })
605
+ steps.append(
606
+ {
607
+ "name": "add_ssh_key",
608
+ "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
609
+ "retries": 0,
610
+ }
611
+ )
504
612
  steps.extend(
505
613
  [
506
614
  {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
@@ -512,6 +620,45 @@ def _build_bootstrap_steps(
512
620
  return steps
513
621
 
514
622
 
623
+ def _guess_package_manager_from_template(template: str) -> str:
624
+ normalized = (template or "").lower()
625
+ if "alpine" in normalized:
626
+ return "apk"
627
+ if "archlinux" in normalized:
628
+ return "pacman"
629
+ if "centos-7" in normalized:
630
+ return "yum"
631
+ if any(keyword in normalized for keyword in ("centos-8", "centos-9", "centos-9-stream", "centos-8-stream")):
632
+ return "dnf"
633
+ if any(keyword in normalized for keyword in ("rockylinux", "almalinux", "fedora")):
634
+ return "dnf"
635
+ if "opensuse" in normalized or "suse" in normalized:
636
+ return "zypper"
637
+ if any(keyword in normalized for keyword in ("debian", "ubuntu", "devuan", "turnkeylinux")):
638
+ return "apt"
639
+ if normalized.startswith("system/") and "linux" in normalized:
640
+ return "apt"
641
+ return "apt"
642
+
643
+
644
+ def _detect_package_manager(vmid: int) -> str:
645
+ candidates = [
646
+ ("apt", "apt-get"),
647
+ ("dnf", "dnf"),
648
+ ("yum", "yum"),
649
+ ("apk", "apk"),
650
+ ("pacman", "pacman"),
651
+ ("zypper", "zypper"),
652
+ ]
653
+ for name, binary in candidates:
654
+ res = _run_pct(vmid, f"command -v {binary} >/dev/null 2>&1")
655
+ if res.get("returncode") == 0:
656
+ logger.debug("Detected package manager %s inside container %s", name, vmid)
657
+ return name
658
+ logger.warning("Unable to detect package manager inside container %s; defaulting to apt", vmid)
659
+ return "apt"
660
+
661
+
515
662
  def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
516
663
  for entry in storages:
517
664
  if entry.get("storage") == storage_name:
@@ -618,89 +765,6 @@ def _remove_container_record(vmid: int) -> None:
618
765
  _invalidate_managed_containers_cache()
619
766
 
620
767
 
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
768
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
705
769
  templates = config.get("templates") or []
706
770
  default_template = templates[0] if templates else ""
@@ -816,116 +880,6 @@ def _run_pct_push(vmid: int, src: str, dest: str) -> subprocess.CompletedProcess
816
880
  return _call_subprocess(["pct", "push", str(vmid), src, dest])
817
881
 
818
882
 
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
883
  def _push_bytes_to_container(
930
884
  vmid: int, user: str, path: str, data: bytes, mode: int = 0o600
931
885
  ) -> None:
@@ -1182,7 +1136,16 @@ def _bootstrap_portacode(
1182
1136
  total_steps: Optional[int] = None,
1183
1137
  default_public_key: Optional[str] = None,
1184
1138
  ) -> Tuple[str, List[Dict[str, Any]]]:
1185
- actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
1139
+ if steps is not None:
1140
+ actual_steps = steps
1141
+ else:
1142
+ detected_manager = _detect_package_manager(vmid)
1143
+ actual_steps = _build_bootstrap_steps(
1144
+ user,
1145
+ password,
1146
+ ssh_key,
1147
+ package_manager=detected_manager,
1148
+ )
1186
1149
  results, ok = _run_setup_steps(
1187
1150
  vmid,
1188
1151
  actual_steps,
@@ -1206,6 +1169,15 @@ def _bootstrap_portacode(
1206
1169
  else:
1207
1170
  command_text = str(command)
1208
1171
  command_suffix = f" command={command_text}" if command_text else ""
1172
+ stdout = details.get("stdout")
1173
+ stderr = details.get("stderr")
1174
+ if stdout or stderr:
1175
+ logger.debug(
1176
+ "Bootstrap command output%s%s%s",
1177
+ f" stdout={stdout!r}" if stdout else "",
1178
+ " " if stdout and stderr else "",
1179
+ f"stderr={stderr!r}" if stderr else "",
1180
+ )
1209
1181
  if summary:
1210
1182
  logger.warning(
1211
1183
  "Portacode bootstrap failure summary=%s%s%s",
@@ -1213,10 +1185,15 @@ def _bootstrap_portacode(
1213
1185
  f" history_len={len(history)}" if history else "",
1214
1186
  f" command={command_text}" if command_text else "",
1215
1187
  )
1216
- raise RuntimeError(
1217
- f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
1218
- )
1219
- raise RuntimeError("Portacode bootstrap steps failed.")
1188
+ logger.error(
1189
+ "Portacode bootstrap command failed%s%s%s",
1190
+ f" command={command_text}" if command_text else "",
1191
+ f" stdout={stdout!r}" if stdout else "",
1192
+ f" stderr={stderr!r}" if stderr else "",
1193
+ )
1194
+ raise RuntimeError(
1195
+ f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
1196
+ )
1220
1197
  key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
1221
1198
  public_key = key_step.get("public_key") if key_step else default_public_key
1222
1199
  if not public_key:
@@ -1224,12 +1201,6 @@ def _bootstrap_portacode(
1224
1201
  return public_key, results
1225
1202
 
1226
1203
 
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
1204
  def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
1234
1205
  network = config.get("network", {})
1235
1206
  base_network = {
@@ -1237,13 +1208,9 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
1237
1208
  "message": network.get("message"),
1238
1209
  "bridge": network.get("bridge", DEFAULT_BRIDGE),
1239
1210
  }
1240
- cloudflare_snapshot = _build_cloudflare_snapshot(config.get("cloudflare"))
1241
1211
  if not config:
1242
- return {
1243
- "configured": False,
1244
- "network": base_network,
1245
- "cloudflare": cloudflare_snapshot,
1246
- }
1212
+ return {"configured": False, "network": base_network}
1213
+ _ensure_templates_refreshed_on_startup(config)
1247
1214
  return {
1248
1215
  "configured": True,
1249
1216
  "host": config.get("host"),
@@ -1254,54 +1221,18 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
1254
1221
  "templates": config.get("templates") or [],
1255
1222
  "last_verified": config.get("last_verified"),
1256
1223
  "network": base_network,
1257
- "cloudflare": cloudflare_snapshot,
1258
1224
  }
1259
1225
 
1260
1226
 
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]:
1227
+ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl: bool = False) -> Dict[str, Any]:
1293
1228
  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)
1229
+ user, token_name = _parse_token(token_identifier)
1299
1230
  client = ProxmoxAPI(
1300
1231
  DEFAULT_HOST,
1301
1232
  user=user,
1302
1233
  token_name=token_name,
1303
- token_value=resolved_token_value,
1304
- verify_ssl=actual_verify_ssl,
1234
+ token_value=token_value,
1235
+ verify_ssl=verify_ssl,
1305
1236
  timeout=30,
1306
1237
  )
1307
1238
  node = _pick_node(client)
@@ -1309,36 +1240,32 @@ def configure_infrastructure(
1309
1240
  storages = client.nodes(node).storage.get()
1310
1241
  default_storage = _pick_storage(storages)
1311
1242
  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
1243
+ network: Dict[str, Any] = {}
1244
+ try:
1245
+ network = _ensure_bridge()
1246
+ # Wait for network convergence before validating connectivity
1247
+ time.sleep(2)
1248
+ if not _verify_connectivity():
1249
+ raise RuntimeError("Connectivity check failed; bridge reverted")
1250
+ network["health"] = "healthy"
1251
+ except Exception as exc:
1252
+ logger.warning("Bridge setup failed; reverting previous changes: %s", exc)
1253
+ _revert_bridge()
1254
+ raise
1326
1255
  config = {
1327
1256
  "host": DEFAULT_HOST,
1328
1257
  "node": node,
1329
1258
  "user": user,
1330
1259
  "token_name": token_name,
1331
- "token_value": resolved_token_value,
1332
- "verify_ssl": actual_verify_ssl,
1260
+ "token_value": token_value,
1261
+ "verify_ssl": verify_ssl,
1333
1262
  "default_storage": default_storage,
1334
- "templates": templates,
1335
1263
  "last_verified": datetime.utcnow().isoformat() + "Z",
1264
+ "templates": templates,
1265
+ "templates_last_refreshed": _current_time_iso(),
1336
1266
  "network": network,
1337
1267
  "node_status": status,
1338
1268
  }
1339
- cloudflare = _build_cloudflare_config(existing, cloudflare_api_token)
1340
- if cloudflare:
1341
- config["cloudflare"] = cloudflare
1342
1269
  _save_config(config)
1343
1270
  snapshot = build_snapshot(config)
1344
1271
  snapshot["node_status"] = status
@@ -1389,7 +1316,7 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
1389
1316
  memory=int(payload["ram_mib"]),
1390
1317
  swap=int(payload.get("swap_mb", 0)),
1391
1318
  cores=max(int(payload.get("cores", 1)), 1),
1392
- cpuunits=int(payload.get("cpuunits", 256)),
1319
+ cpulimit=float(payload.get("cpulimit", payload.get("cpus", 1))),
1393
1320
  net0=payload["net0"],
1394
1321
  unprivileged=int(payload.get("unprivileged", 1)),
1395
1322
  description=payload.get("description", MANAGED_MARKER),
@@ -1412,16 +1339,24 @@ class CreateProxmoxContainerHandler(SyncHandler):
1412
1339
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1413
1340
  logger.info("create_proxmox_container command received")
1414
1341
  request_id = message.get("request_id")
1415
- device_id = message.get("device_id")
1342
+ raw_device_id = message.get("device_id")
1343
+ device_id = str(raw_device_id or "").strip()
1344
+ if not device_id:
1345
+ raise ValueError("device_id is required to create a container")
1416
1346
  device_public_key = (message.get("device_public_key") or "").strip()
1417
1347
  device_private_key = (message.get("device_private_key") or "").strip()
1418
1348
  has_device_keypair = bool(device_public_key and device_private_key)
1349
+ config_guess = _load_config()
1350
+ template_candidates = config_guess.get("templates") or []
1351
+ template_hint = (message.get("template") or (template_candidates[0] if template_candidates else "")).strip()
1352
+ package_manager = _guess_package_manager_from_template(template_hint)
1419
1353
  bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
1420
1354
  bootstrap_steps = _build_bootstrap_steps(
1421
1355
  bootstrap_user,
1422
1356
  bootstrap_password,
1423
1357
  bootstrap_ssh_key,
1424
1358
  include_portacode_connect=not has_device_keypair,
1359
+ package_manager=package_manager,
1425
1360
  )
1426
1361
  total_steps = 3 + len(bootstrap_steps) + 2
1427
1362
  current_step_index = 1
@@ -1444,6 +1379,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1444
1379
  message=start_message,
1445
1380
  phase="lifecycle",
1446
1381
  request_id=request_id,
1382
+ on_behalf_of_device=device_id,
1447
1383
  )
1448
1384
  try:
1449
1385
  result = action()
@@ -1459,6 +1395,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1459
1395
  phase="lifecycle",
1460
1396
  request_id=request_id,
1461
1397
  details={"error": str(exc)},
1398
+ on_behalf_of_device=device_id,
1462
1399
  )
1463
1400
  raise
1464
1401
  _emit_progress_event(
@@ -1471,6 +1408,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1471
1408
  message=success_message,
1472
1409
  phase="lifecycle",
1473
1410
  request_id=request_id,
1411
+ on_behalf_of_device=device_id,
1474
1412
  )
1475
1413
  current_step_index += 1
1476
1414
  return result
@@ -1497,7 +1435,8 @@ class CreateProxmoxContainerHandler(SyncHandler):
1497
1435
  proxmox = _connect_proxmox(config)
1498
1436
  node = config.get("node") or DEFAULT_NODE_NAME
1499
1437
  payload = _build_container_payload(message, config)
1500
- payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
1438
+ payload["cpulimit"] = float(payload["cpus"])
1439
+ payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
1501
1440
  payload["memory"] = int(payload["ram_mib"])
1502
1441
  payload["node"] = node
1503
1442
  logger.debug(
@@ -1512,6 +1451,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1512
1451
  payload["vmid"] = vmid
1513
1452
  payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1514
1453
  payload["status"] = "creating"
1454
+ payload["device_id"] = device_id
1515
1455
  _write_container_record(vmid, payload)
1516
1456
  return proxmox, node, vmid, payload
1517
1457
 
@@ -1572,6 +1512,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1572
1512
  phase="bootstrap",
1573
1513
  request_id=request_id,
1574
1514
  details=details or None,
1515
+ on_behalf_of_device=device_id,
1575
1516
  )
1576
1517
 
1577
1518
  public_key, steps = _bootstrap_portacode(
@@ -1615,6 +1556,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1615
1556
  message="Notifying the server of the new device…",
1616
1557
  phase="service",
1617
1558
  request_id=request_id,
1559
+ on_behalf_of_device=device_id,
1618
1560
  )
1619
1561
  _emit_progress_event(
1620
1562
  self,
@@ -1626,6 +1568,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1626
1568
  message="Authentication metadata recorded.",
1627
1569
  phase="service",
1628
1570
  request_id=request_id,
1571
+ on_behalf_of_device=device_id,
1629
1572
  )
1630
1573
 
1631
1574
  install_step = service_start_index + 1
@@ -1640,6 +1583,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1640
1583
  message="Running sudo portacode service install…",
1641
1584
  phase="service",
1642
1585
  request_id=request_id,
1586
+ on_behalf_of_device=device_id,
1643
1587
  )
1644
1588
 
1645
1589
  cmd = f"su - {payload['username']} -c 'sudo -S portacode service install'"
@@ -1660,6 +1604,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1660
1604
  "stderr": res.get("stderr"),
1661
1605
  "stdout": res.get("stdout"),
1662
1606
  },
1607
+ on_behalf_of_device=device_id,
1663
1608
  )
1664
1609
  raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1665
1610
 
@@ -1673,6 +1618,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1673
1618
  message="Portacode service install finished.",
1674
1619
  phase="service",
1675
1620
  request_id=request_id,
1621
+ on_behalf_of_device=device_id,
1676
1622
  )
1677
1623
 
1678
1624
  logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
@@ -1696,6 +1642,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1696
1642
  },
1697
1643
  "setup_steps": steps,
1698
1644
  "device_id": device_id,
1645
+ "on_behalf_of_device": device_id,
1699
1646
  "service_installed": service_installed,
1700
1647
  }
1701
1648
 
@@ -1721,6 +1668,9 @@ class StartPortacodeServiceHandler(SyncHandler):
1721
1668
  password = record.get("password")
1722
1669
  if not user or not password:
1723
1670
  raise RuntimeError("Container credentials unavailable")
1671
+ on_behalf_of_device = record.get("device_id")
1672
+ if on_behalf_of_device:
1673
+ on_behalf_of_device = str(on_behalf_of_device)
1724
1674
 
1725
1675
  start_index = int(message.get("step_index", 1))
1726
1676
  total_steps = int(message.get("total_steps", start_index + 2))
@@ -1738,6 +1688,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1738
1688
  message="Notifying the server of the new device…",
1739
1689
  phase="service",
1740
1690
  request_id=request_id,
1691
+ on_behalf_of_device=on_behalf_of_device,
1741
1692
  )
1742
1693
  _emit_progress_event(
1743
1694
  self,
@@ -1749,6 +1700,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1749
1700
  message="Authentication metadata recorded.",
1750
1701
  phase="service",
1751
1702
  request_id=request_id,
1703
+ on_behalf_of_device=on_behalf_of_device,
1752
1704
  )
1753
1705
 
1754
1706
  install_step = start_index + 1
@@ -1763,6 +1715,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1763
1715
  message="Running sudo portacode service install…",
1764
1716
  phase="service",
1765
1717
  request_id=request_id,
1718
+ on_behalf_of_device=on_behalf_of_device,
1766
1719
  )
1767
1720
 
1768
1721
  cmd = f"su - {user} -c 'sudo -S portacode service install'"
@@ -1783,6 +1736,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1783
1736
  "stderr": res.get("stderr"),
1784
1737
  "stdout": res.get("stdout"),
1785
1738
  },
1739
+ on_behalf_of_device=on_behalf_of_device,
1786
1740
  )
1787
1741
  raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1788
1742
 
@@ -1796,6 +1750,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1796
1750
  message="Portacode service install finished.",
1797
1751
  phase="service",
1798
1752
  request_id=request_id,
1753
+ on_behalf_of_device=on_behalf_of_device,
1799
1754
  )
1800
1755
 
1801
1756
  return {
@@ -1822,10 +1777,6 @@ class StartProxmoxContainerHandler(SyncHandler):
1822
1777
 
1823
1778
  status, elapsed = _start_container(proxmox, node, vmid)
1824
1779
  _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
1780
 
1830
1781
  infra = get_infra_snapshot()
1831
1782
  return {
@@ -1855,7 +1806,6 @@ class StopProxmoxContainerHandler(SyncHandler):
1855
1806
  _ensure_container_managed(proxmox, node, vmid)
1856
1807
 
1857
1808
  status, elapsed = _stop_container(proxmox, node, vmid)
1858
- _stop_container_tunnel(vmid)
1859
1809
  final_status = status.get("status") or "stopped"
1860
1810
  _update_container_record(vmid, {"status": final_status})
1861
1811
 
@@ -1891,13 +1841,8 @@ class RemoveProxmoxContainerHandler(SyncHandler):
1891
1841
  node = _get_node_from_config(config)
1892
1842
  _ensure_container_managed(proxmox, node, vmid)
1893
1843
 
1894
- _stop_container_tunnel(vmid)
1895
1844
  stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
1896
1845
  delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
1897
- try:
1898
- _update_container_tunnel(vmid, None)
1899
- except FileNotFoundError:
1900
- pass
1901
1846
  _remove_container_record(vmid)
1902
1847
 
1903
1848
  infra = get_infra_snapshot()
@@ -1916,134 +1861,6 @@ class RemoveProxmoxContainerHandler(SyncHandler):
1916
1861
  }
1917
1862
 
1918
1863
 
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
1864
  class ConfigureProxmoxInfraHandler(SyncHandler):
2048
1865
  @property
2049
1866
  def command_name(self) -> str:
@@ -2052,13 +1869,10 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
2052
1869
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
2053
1870
  token_identifier = message.get("token_identifier")
2054
1871
  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
- )
1872
+ verify_ssl = bool(message.get("verify_ssl"))
1873
+ if not token_identifier or not token_value:
1874
+ raise ValueError("token_identifier and token_value are required")
1875
+ snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
2062
1876
  return {
2063
1877
  "event": "proxmox_infra_configured",
2064
1878
  "success": True,