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.
- portacode/_version.py +2 -2
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +14 -102
- portacode/connection/handlers/__init__.py +0 -6
- portacode/connection/handlers/proxmox_infra.py +292 -478
- {portacode-1.4.15.dev3.dist-info → portacode-1.4.15.dev15.dist-info}/METADATA +1 -1
- {portacode-1.4.15.dev3.dist-info → portacode-1.4.15.dev15.dist-info}/RECORD +10 -10
- {portacode-1.4.15.dev3.dist-info → portacode-1.4.15.dev15.dist-info}/WHEEL +1 -1
- {portacode-1.4.15.dev3.dist-info → portacode-1.4.15.dev15.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.15.dev3.dist-info → portacode-1.4.15.dev15.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.15.dev3.dist-info → portacode-1.4.15.dev15.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
"
|
|
476
|
-
"
|
|
477
|
-
"
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
"
|
|
487
|
-
"
|
|
488
|
-
"
|
|
489
|
-
"
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
1304
|
-
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
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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":
|
|
1332
|
-
"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
|
-
|
|
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
|
-
|
|
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["
|
|
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
|
-
|
|
2057
|
-
token_identifier
|
|
2058
|
-
|
|
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,
|