portacode 1.4.12.dev1__py3-none-any.whl → 1.4.15.dev3__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.
@@ -7,15 +7,20 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import secrets
10
+ import shlex
11
+ import re
12
+ import select
10
13
  import shutil
11
14
  import stat
12
15
  import subprocess
13
16
  import sys
17
+ import tempfile
14
18
  import time
15
19
  import threading
20
+ import urllib.request
16
21
  from datetime import datetime
17
22
  from pathlib import Path
18
- from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
23
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
19
24
 
20
25
  import platformdirs
21
26
 
@@ -38,12 +43,15 @@ BRIDGE_IP = SUBNET_CIDR.split("/", 1)[0]
38
43
  DHCP_START = "10.10.0.100"
39
44
  DHCP_END = "10.10.0.200"
40
45
  DNS_SERVER = "1.1.1.1"
46
+ CLOUDFLARE_DEB_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb"
41
47
  IFACES_PATH = Path("/etc/network/interfaces")
42
48
  SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
43
49
  UNIT_DIR = Path("/etc/systemd/system")
44
50
  _MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
45
51
  _MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
46
52
  _MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
53
+ _CLOUDFLARE_TUNNEL_PROCESSES: Dict[str, subprocess.Popen] = {}
54
+ _CLOUDFLARE_TUNNELS_LOCK = threading.Lock()
47
55
 
48
56
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
49
57
 
@@ -261,7 +269,10 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
261
269
  apt = shutil.which("apt-get")
262
270
  if not apt:
263
271
  raise RuntimeError("dnsmasq is missing and apt-get unavailable to install it")
264
- _call_subprocess([apt, "update"], check=True)
272
+ update = _call_subprocess([apt, "update"], check=False)
273
+ if update.returncode not in (0, 100):
274
+ msg = update.stderr or update.stdout or f"exit status {update.returncode}"
275
+ raise RuntimeError(f"apt-get update failed: {msg}")
265
276
  _call_subprocess([apt, "install", "-y", "dnsmasq"], check=True)
266
277
  _write_bridge_config(bridge)
267
278
  _ensure_sysctl()
@@ -274,6 +285,25 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
274
285
  return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
275
286
 
276
287
 
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
+
277
307
  def _verify_connectivity(timeout: float = 5.0) -> bool:
278
308
  try:
279
309
  _call_subprocess(["/bin/ping", "-c", "2", "1.1.1.1"], check=True, timeout=timeout)
@@ -350,6 +380,7 @@ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str
350
380
  "cpu_share": cpu_share,
351
381
  "created_at": record.get("created_at"),
352
382
  "status": status,
383
+ "tunnel": record.get("tunnel"),
353
384
  }
354
385
  )
355
386
 
@@ -428,7 +459,12 @@ def _friendly_step_label(step_name: str) -> str:
428
459
  return normalized.capitalize()
429
460
 
430
461
 
431
- def _build_bootstrap_steps(user: str, password: str, ssh_key: str) -> List[Dict[str, Any]]:
462
+ def _build_bootstrap_steps(
463
+ user: str,
464
+ password: str,
465
+ ssh_key: str,
466
+ include_portacode_connect: bool = True,
467
+ ) -> List[Dict[str, Any]]:
432
468
  steps = [
433
469
  {
434
470
  "name": "apt_update",
@@ -465,11 +501,14 @@ def _build_bootstrap_steps(user: str, password: str, ssh_key: str) -> List[Dict[
465
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",
466
502
  "retries": 0,
467
503
  })
468
- steps.extend([
469
- {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
470
- {"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
471
- {"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30},
472
- ])
504
+ steps.extend(
505
+ [
506
+ {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
507
+ {"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
508
+ ]
509
+ )
510
+ if include_portacode_connect:
511
+ steps.append({"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30})
473
512
  return steps
474
513
 
475
514
 
@@ -579,6 +618,89 @@ def _remove_container_record(vmid: int) -> None:
579
618
  _invalidate_managed_containers_cache()
580
619
 
581
620
 
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
+
582
704
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
583
705
  templates = config.get("templates") or []
584
706
  default_template = templates[0] if templates else ""
@@ -590,7 +712,7 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
590
712
  hostname = (message.get("hostname") or "").strip()
591
713
  disk_gib = int(max(round(_validate_positive_number(message.get("disk_gib") or message.get("disk"), 3)), 1))
592
714
  ram_mib = int(max(round(_validate_positive_number(message.get("ram_mib") or message.get("ram"), 2048)), 1))
593
- cpus = _validate_positive_number(message.get("cpus"), 1)
715
+ cpus = _validate_positive_number(message.get("cpus"), 0.2)
594
716
  storage = message.get("storage") or config.get("default_storage") or ""
595
717
  if not storage:
596
718
  raise ValueError("Storage pool could not be determined.")
@@ -679,6 +801,184 @@ def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
679
801
  return res
680
802
 
681
803
 
804
+ def _run_pct_exec(vmid: int, command: Sequence[str]) -> subprocess.CompletedProcess[str]:
805
+ return _call_subprocess(["pct", "exec", str(vmid), "--", *command])
806
+
807
+
808
+ def _run_pct_exec_check(vmid: int, command: Sequence[str]) -> subprocess.CompletedProcess[str]:
809
+ res = _run_pct_exec(vmid, command)
810
+ if res.returncode != 0:
811
+ raise RuntimeError(res.stderr or res.stdout or f"pct exec {' '.join(command)} failed")
812
+ return res
813
+
814
+
815
+ def _run_pct_push(vmid: int, src: str, dest: str) -> subprocess.CompletedProcess[str]:
816
+ return _call_subprocess(["pct", "push", str(vmid), src, dest])
817
+
818
+
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
+ def _push_bytes_to_container(
930
+ vmid: int, user: str, path: str, data: bytes, mode: int = 0o600
931
+ ) -> None:
932
+ logger.debug("Preparing to push %d bytes to container vmid=%s path=%s for user=%s", len(data), vmid, path, user)
933
+ tmp_path: Optional[str] = None
934
+ try:
935
+ parent = Path(path).parent
936
+ parent_str = parent.as_posix()
937
+ if parent_str not in {"", ".", "/"}:
938
+ _run_pct_exec_check(vmid, ["mkdir", "-p", parent_str])
939
+ _run_pct_exec_check(vmid, ["chown", "-R", f"{user}:{user}", parent_str])
940
+
941
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
942
+ tmp.write(data)
943
+ tmp.flush()
944
+ os.fsync(tmp.fileno())
945
+ tmp_path = tmp.name
946
+
947
+ push_res = _run_pct_push(vmid, tmp_path, path)
948
+ if push_res.returncode != 0:
949
+ raise RuntimeError(push_res.stderr or push_res.stdout or f"pct push returned {push_res.returncode}")
950
+
951
+ _run_pct_exec_check(vmid, ["chown", f"{user}:{user}", path])
952
+ _run_pct_exec_check(vmid, ["chmod", format(mode, "o"), path])
953
+ logger.debug("Successfully pushed %d bytes to vmid=%s path=%s", len(data), vmid, path)
954
+ except Exception as exc:
955
+ logger.error("Failed to write to container vmid=%s path=%s for user=%s: %s", vmid, path, user, exc)
956
+ raise
957
+ finally:
958
+ if tmp_path:
959
+ try:
960
+ os.remove(tmp_path)
961
+ except OSError as cleanup_exc:
962
+ logger.warning("Failed to remove temporary file %s: %s", tmp_path, cleanup_exc)
963
+
964
+
965
+ def _resolve_portacode_key_dir(vmid: int, user: str) -> str:
966
+ data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
967
+ data_home = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
968
+ portacode_dir = f"{data_home}/portacode"
969
+ _run_pct_exec_check(vmid, ["mkdir", "-p", portacode_dir])
970
+ _run_pct_exec_check(vmid, ["chown", "-R", f"{user}:{user}", portacode_dir])
971
+ return f"{portacode_dir}/keys"
972
+
973
+
974
+ def _deploy_device_keypair(vmid: int, user: str, private_key: str, public_key: str) -> None:
975
+ key_dir = _resolve_portacode_key_dir(vmid, user)
976
+ priv_path = f"{key_dir}/id_portacode"
977
+ pub_path = f"{key_dir}/id_portacode.pub"
978
+ _push_bytes_to_container(vmid, user, priv_path, private_key.encode(), mode=0o600)
979
+ _push_bytes_to_container(vmid, user, pub_path, public_key.encode(), mode=0o644)
980
+
981
+
682
982
  def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
683
983
  cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
684
984
  proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@@ -702,15 +1002,22 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
702
1002
 
703
1003
  last_pub = last_priv = None
704
1004
  stable = 0
1005
+ history: List[Dict[str, Any]] = []
1006
+
1007
+ process_exited = False
1008
+ exit_out = exit_err = ""
705
1009
  while time.time() - start < timeout_s:
706
1010
  if proc.poll() is not None:
707
- out, err = proc.communicate(timeout=1)
708
- return {
709
- "ok": False,
710
- "error": "portacode connect exited before keys were created",
711
- "stdout": (out or "").strip(),
712
- "stderr": (err or "").strip(),
713
- }
1011
+ process_exited = True
1012
+ exit_out, exit_err = proc.communicate(timeout=1)
1013
+ history.append(
1014
+ {
1015
+ "timestamp_s": round(time.time() - start, 2),
1016
+ "status": "process_exited",
1017
+ "returncode": proc.returncode,
1018
+ }
1019
+ )
1020
+ break
714
1021
  pub_size = file_size(pub_path)
715
1022
  priv_size = file_size(priv_path)
716
1023
  if pub_size and priv_size:
@@ -720,21 +1027,60 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
720
1027
  stable = 0
721
1028
  last_pub, last_priv = pub_size, priv_size
722
1029
  if stable >= 1:
1030
+ history.append(
1031
+ {
1032
+ "timestamp_s": round(time.time() - start, 2),
1033
+ "pub_size": pub_size,
1034
+ "priv_size": priv_size,
1035
+ "stable": stable,
1036
+ }
1037
+ )
723
1038
  break
1039
+ history.append(
1040
+ {
1041
+ "timestamp_s": round(time.time() - start, 2),
1042
+ "pub_size": pub_size,
1043
+ "priv_size": priv_size,
1044
+ "stable": stable,
1045
+ }
1046
+ )
724
1047
  time.sleep(1)
725
1048
 
726
- if stable < 1:
1049
+ final_pub = file_size(pub_path)
1050
+ final_priv = file_size(priv_path)
1051
+ if final_pub and final_priv:
1052
+ key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
1053
+ if not process_exited:
1054
+ proc.terminate()
1055
+ try:
1056
+ proc.wait(timeout=3)
1057
+ except subprocess.TimeoutExpired:
1058
+ proc.kill()
1059
+ return {
1060
+ "ok": True,
1061
+ "public_key": key_res["stdout"].strip(),
1062
+ "history": history,
1063
+ }
1064
+
1065
+ if not process_exited:
727
1066
  proc.terminate()
728
1067
  try:
729
1068
  proc.wait(timeout=3)
730
1069
  except subprocess.TimeoutExpired:
731
1070
  proc.kill()
732
- out, err = proc.communicate(timeout=1)
1071
+ exit_out, exit_err = proc.communicate(timeout=1)
1072
+ history.append(
1073
+ {
1074
+ "timestamp_s": round(time.time() - start, 2),
1075
+ "status": "timeout_waiting_for_keys",
1076
+ }
1077
+ )
733
1078
  return {
734
1079
  "ok": False,
735
1080
  "error": "timed out waiting for portacode key files",
736
- "stdout": (out or "").strip(),
737
- "stderr": (err or "").strip(),
1081
+ "stdout": (exit_out or "").strip(),
1082
+ "stderr": (exit_err or "").strip(),
1083
+ "history": history,
738
1084
  }
739
1085
 
740
1086
  proc.terminate()
@@ -747,6 +1093,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
747
1093
  return {
748
1094
  "ok": True,
749
1095
  "public_key": key_res["stdout"].strip(),
1096
+ "history": history,
750
1097
  }
751
1098
 
752
1099
 
@@ -833,6 +1180,7 @@ def _bootstrap_portacode(
833
1180
  progress_callback: Optional[ProgressCallback] = None,
834
1181
  start_index: int = 1,
835
1182
  total_steps: Optional[int] = None,
1183
+ default_public_key: Optional[str] = None,
836
1184
  ) -> Tuple[str, List[Dict[str, Any]]]:
837
1185
  actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
838
1186
  results, ok = _run_setup_steps(
@@ -844,14 +1192,44 @@ def _bootstrap_portacode(
844
1192
  total_steps=total_steps,
845
1193
  )
846
1194
  if not ok:
1195
+ details = results[-1] if results else {}
1196
+ summary = details.get("error_summary") or details.get("stderr") or details.get("stdout") or details.get("name")
1197
+ history = details.get("history")
1198
+ history_snippet = ""
1199
+ if isinstance(history, list) and history:
1200
+ history_snippet = f" history={history[-3:]}"
1201
+ command = details.get("cmd")
1202
+ command_text = ""
1203
+ if command:
1204
+ if isinstance(command, (list, tuple)):
1205
+ command_text = shlex.join(str(entry) for entry in command)
1206
+ else:
1207
+ command_text = str(command)
1208
+ command_suffix = f" command={command_text}" if command_text else ""
1209
+ if summary:
1210
+ logger.warning(
1211
+ "Portacode bootstrap failure summary=%s%s%s",
1212
+ summary,
1213
+ f" history_len={len(history)}" if history else "",
1214
+ f" command={command_text}" if command_text else "",
1215
+ )
1216
+ raise RuntimeError(
1217
+ f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
1218
+ )
847
1219
  raise RuntimeError("Portacode bootstrap steps failed.")
848
1220
  key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
849
- public_key = key_step.get("public_key") if key_step else None
1221
+ public_key = key_step.get("public_key") if key_step else default_public_key
850
1222
  if not public_key:
851
1223
  raise RuntimeError("Portacode connect did not return a public key.")
852
1224
  return public_key, results
853
1225
 
854
1226
 
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
+
855
1233
  def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
856
1234
  network = config.get("network", {})
857
1235
  base_network = {
@@ -859,8 +1237,13 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
859
1237
  "message": network.get("message"),
860
1238
  "bridge": network.get("bridge", DEFAULT_BRIDGE),
861
1239
  }
1240
+ cloudflare_snapshot = _build_cloudflare_snapshot(config.get("cloudflare"))
862
1241
  if not config:
863
- return {"configured": False, "network": base_network}
1242
+ return {
1243
+ "configured": False,
1244
+ "network": base_network,
1245
+ "cloudflare": cloudflare_snapshot,
1246
+ }
864
1247
  return {
865
1248
  "configured": True,
866
1249
  "host": config.get("host"),
@@ -871,18 +1254,54 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
871
1254
  "templates": config.get("templates") or [],
872
1255
  "last_verified": config.get("last_verified"),
873
1256
  "network": base_network,
1257
+ "cloudflare": cloudflare_snapshot,
874
1258
  }
875
1259
 
876
1260
 
877
- def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl: bool = False) -> Dict[str, Any]:
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]:
878
1293
  ProxmoxAPI = _ensure_proxmoxer()
879
- user, token_name = _parse_token(token_identifier)
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)
880
1299
  client = ProxmoxAPI(
881
1300
  DEFAULT_HOST,
882
1301
  user=user,
883
1302
  token_name=token_name,
884
- token_value=token_value,
885
- verify_ssl=verify_ssl,
1303
+ token_value=resolved_token_value,
1304
+ verify_ssl=actual_verify_ssl,
886
1305
  timeout=30,
887
1306
  )
888
1307
  node = _pick_node(client)
@@ -890,35 +1309,36 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
890
1309
  storages = client.nodes(node).storage.get()
891
1310
  default_storage = _pick_storage(storages)
892
1311
  templates = _list_templates(client, node, storages)
893
- network: Dict[str, Any] = {}
894
- try:
895
- network = _ensure_bridge()
896
- # Wait for network convergence before validating connectivity
897
- time.sleep(2)
898
- if _verify_connectivity():
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")
899
1321
  network["health"] = "healthy"
900
- else:
901
- network = {"applied": False, "bridge": DEFAULT_BRIDGE, "message": "Connectivity check failed; bridge reverted"}
1322
+ except Exception as exc:
1323
+ logger.warning("Bridge setup failed; reverting previous changes: %s", exc)
902
1324
  _revert_bridge()
903
- except PermissionError as exc:
904
- network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
905
- logger.warning("Bridge setup skipped: %s", exc)
906
- except Exception as exc: # pragma: no cover - best effort
907
- network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
908
- logger.warning("Bridge setup failed: %s", exc)
1325
+ raise
909
1326
  config = {
910
1327
  "host": DEFAULT_HOST,
911
1328
  "node": node,
912
1329
  "user": user,
913
1330
  "token_name": token_name,
914
- "token_value": token_value,
915
- "verify_ssl": verify_ssl,
1331
+ "token_value": resolved_token_value,
1332
+ "verify_ssl": actual_verify_ssl,
916
1333
  "default_storage": default_storage,
917
1334
  "templates": templates,
918
1335
  "last_verified": datetime.utcnow().isoformat() + "Z",
919
1336
  "network": network,
920
1337
  "node_status": status,
921
1338
  }
1339
+ cloudflare = _build_cloudflare_config(existing, cloudflare_api_token)
1340
+ if cloudflare:
1341
+ config["cloudflare"] = cloudflare
922
1342
  _save_config(config)
923
1343
  snapshot = build_snapshot(config)
924
1344
  snapshot["node_status"] = status
@@ -968,7 +1388,7 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
968
1388
  rootfs=rootfs,
969
1389
  memory=int(payload["ram_mib"]),
970
1390
  swap=int(payload.get("swap_mb", 0)),
971
- cores=int(payload.get("cpus", 1)),
1391
+ cores=max(int(payload.get("cores", 1)), 1),
972
1392
  cpuunits=int(payload.get("cpuunits", 256)),
973
1393
  net0=payload["net0"],
974
1394
  unprivileged=int(payload.get("unprivileged", 1)),
@@ -992,8 +1412,17 @@ class CreateProxmoxContainerHandler(SyncHandler):
992
1412
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
993
1413
  logger.info("create_proxmox_container command received")
994
1414
  request_id = message.get("request_id")
1415
+ device_id = message.get("device_id")
1416
+ device_public_key = (message.get("device_public_key") or "").strip()
1417
+ device_private_key = (message.get("device_private_key") or "").strip()
1418
+ has_device_keypair = bool(device_public_key and device_private_key)
995
1419
  bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
996
- bootstrap_steps = _build_bootstrap_steps(bootstrap_user, bootstrap_password, bootstrap_ssh_key)
1420
+ bootstrap_steps = _build_bootstrap_steps(
1421
+ bootstrap_user,
1422
+ bootstrap_password,
1423
+ bootstrap_ssh_key,
1424
+ include_portacode_connect=not has_device_keypair,
1425
+ )
997
1426
  total_steps = 3 + len(bootstrap_steps) + 2
998
1427
  current_step_index = 1
999
1428
 
@@ -1154,9 +1583,102 @@ class CreateProxmoxContainerHandler(SyncHandler):
1154
1583
  progress_callback=_bootstrap_progress_callback,
1155
1584
  start_index=current_step_index,
1156
1585
  total_steps=total_steps,
1586
+ default_public_key=device_public_key if has_device_keypair else None,
1157
1587
  )
1158
1588
  current_step_index += len(bootstrap_steps)
1159
1589
 
1590
+ service_installed = False
1591
+ if has_device_keypair:
1592
+ logger.info(
1593
+ "deploying dashboard-provided Portacode keypair (device_id=%s) into container %s",
1594
+ device_id,
1595
+ vmid,
1596
+ )
1597
+ _deploy_device_keypair(
1598
+ vmid,
1599
+ payload["username"],
1600
+ device_private_key,
1601
+ device_public_key,
1602
+ )
1603
+ service_installed = True
1604
+ service_start_index = current_step_index
1605
+
1606
+ auth_step_name = "setup_device_authentication"
1607
+ auth_label = "Setting up device authentication"
1608
+ _emit_progress_event(
1609
+ self,
1610
+ step_index=service_start_index,
1611
+ total_steps=total_steps,
1612
+ step_name=auth_step_name,
1613
+ step_label=auth_label,
1614
+ status="in_progress",
1615
+ message="Notifying the server of the new device…",
1616
+ phase="service",
1617
+ request_id=request_id,
1618
+ )
1619
+ _emit_progress_event(
1620
+ self,
1621
+ step_index=service_start_index,
1622
+ total_steps=total_steps,
1623
+ step_name=auth_step_name,
1624
+ step_label=auth_label,
1625
+ status="completed",
1626
+ message="Authentication metadata recorded.",
1627
+ phase="service",
1628
+ request_id=request_id,
1629
+ )
1630
+
1631
+ install_step = service_start_index + 1
1632
+ install_label = "Launching Portacode service"
1633
+ _emit_progress_event(
1634
+ self,
1635
+ step_index=install_step,
1636
+ total_steps=total_steps,
1637
+ step_name="launch_portacode_service",
1638
+ step_label=install_label,
1639
+ status="in_progress",
1640
+ message="Running sudo portacode service install…",
1641
+ phase="service",
1642
+ request_id=request_id,
1643
+ )
1644
+
1645
+ cmd = f"su - {payload['username']} -c 'sudo -S portacode service install'"
1646
+ res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
1647
+
1648
+ if res["returncode"] != 0:
1649
+ _emit_progress_event(
1650
+ self,
1651
+ step_index=install_step,
1652
+ total_steps=total_steps,
1653
+ step_name="launch_portacode_service",
1654
+ step_label=install_label,
1655
+ status="failed",
1656
+ message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
1657
+ phase="service",
1658
+ request_id=request_id,
1659
+ details={
1660
+ "stderr": res.get("stderr"),
1661
+ "stdout": res.get("stdout"),
1662
+ },
1663
+ )
1664
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1665
+
1666
+ _emit_progress_event(
1667
+ self,
1668
+ step_index=install_step,
1669
+ total_steps=total_steps,
1670
+ step_name="launch_portacode_service",
1671
+ step_label=install_label,
1672
+ status="completed",
1673
+ message="Portacode service install finished.",
1674
+ phase="service",
1675
+ request_id=request_id,
1676
+ )
1677
+
1678
+ logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
1679
+
1680
+ current_step_index += 2
1681
+
1160
1682
  return {
1161
1683
  "event": "proxmox_container_created",
1162
1684
  "success": True,
@@ -1173,6 +1695,8 @@ class CreateProxmoxContainerHandler(SyncHandler):
1173
1695
  "cpus": payload["cpus"],
1174
1696
  },
1175
1697
  "setup_steps": steps,
1698
+ "device_id": device_id,
1699
+ "service_installed": service_installed,
1176
1700
  }
1177
1701
 
1178
1702
 
@@ -1298,6 +1822,10 @@ class StartProxmoxContainerHandler(SyncHandler):
1298
1822
 
1299
1823
  status, elapsed = _start_container(proxmox, node, vmid)
1300
1824
  _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
1301
1829
 
1302
1830
  infra = get_infra_snapshot()
1303
1831
  return {
@@ -1327,6 +1855,7 @@ class StopProxmoxContainerHandler(SyncHandler):
1327
1855
  _ensure_container_managed(proxmox, node, vmid)
1328
1856
 
1329
1857
  status, elapsed = _stop_container(proxmox, node, vmid)
1858
+ _stop_container_tunnel(vmid)
1330
1859
  final_status = status.get("status") or "stopped"
1331
1860
  _update_container_record(vmid, {"status": final_status})
1332
1861
 
@@ -1362,8 +1891,13 @@ class RemoveProxmoxContainerHandler(SyncHandler):
1362
1891
  node = _get_node_from_config(config)
1363
1892
  _ensure_container_managed(proxmox, node, vmid)
1364
1893
 
1894
+ _stop_container_tunnel(vmid)
1365
1895
  stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
1366
1896
  delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
1897
+ try:
1898
+ _update_container_tunnel(vmid, None)
1899
+ except FileNotFoundError:
1900
+ pass
1367
1901
  _remove_container_record(vmid)
1368
1902
 
1369
1903
  infra = get_infra_snapshot()
@@ -1382,6 +1916,134 @@ class RemoveProxmoxContainerHandler(SyncHandler):
1382
1916
  }
1383
1917
 
1384
1918
 
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
+
1385
2047
  class ConfigureProxmoxInfraHandler(SyncHandler):
1386
2048
  @property
1387
2049
  def command_name(self) -> str:
@@ -1390,10 +2052,13 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
1390
2052
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1391
2053
  token_identifier = message.get("token_identifier")
1392
2054
  token_value = message.get("token_value")
1393
- verify_ssl = bool(message.get("verify_ssl"))
1394
- if not token_identifier or not token_value:
1395
- raise ValueError("token_identifier and token_value are required")
1396
- snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
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
+ )
1397
2062
  return {
1398
2063
  "event": "proxmox_infra_configured",
1399
2064
  "success": True,