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.
- portacode/_version.py +2 -2
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +106 -3
- portacode/connection/handlers/__init__.py +6 -0
- portacode/connection/handlers/proxmox_infra.py +713 -48
- portacode/connection/handlers/system_handlers.py +131 -2
- portacode/connection/handlers/test_proxmox_infra.py +13 -0
- portacode/connection/terminal.py +6 -0
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev3.dist-info}/METADATA +1 -1
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev3.dist-info}/RECORD +13 -12
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev3.dist-info}/WHEEL +1 -1
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev3.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev3.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev3.dist-info}/top_level.txt +0 -0
|
@@ -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=
|
|
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(
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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"),
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": (
|
|
737
|
-
"stderr": (
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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=
|
|
885
|
-
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
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
901
|
-
|
|
1322
|
+
except Exception as exc:
|
|
1323
|
+
logger.warning("Bridge setup failed; reverting previous changes: %s", exc)
|
|
902
1324
|
_revert_bridge()
|
|
903
|
-
|
|
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":
|
|
915
|
-
"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("
|
|
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(
|
|
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 =
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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,
|