proxcli 0.1.0__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.
proxmox/client/auth.py ADDED
@@ -0,0 +1,113 @@
1
+ """Authentication manager for Proxmox VE API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from proxmox.client.exceptions import AuthError
12
+
13
+
14
+ class AuthMethod(str, Enum):
15
+ PASSWORD = "password"
16
+ API_TOKEN = "api_token"
17
+
18
+
19
+ class AuthManager:
20
+ """Handles Proxmox authentication (password-based ticket or API token).
21
+
22
+ For password auth: acquires a ticket and CSRF token via
23
+ POST /api2/json/access/ticket, then injects Cookie + CSRFPreventionToken headers.
24
+ For API token auth: base64-encodes the token and sets an Authorization header.
25
+
26
+ Caches credentials in memory for the process lifetime.
27
+ """
28
+
29
+ def __init__(self):
30
+ self._method: AuthMethod | None = None
31
+ self._ticket: str | None = None
32
+ self._csrf_token: str | None = None
33
+ self._auth_header: str | None = None
34
+
35
+ # ------------------------------------------------------------------
36
+ # Public API
37
+ # ------------------------------------------------------------------
38
+
39
+ def authenticate_password(
40
+ self, base_url: str, username: str, password: str, *, verify: bool = True
41
+ ) -> None:
42
+ """Acquire a ticket from Proxmox using username + password."""
43
+ url = f"{base_url}/api2/json/access/ticket"
44
+ try:
45
+ resp = httpx.post(
46
+ url,
47
+ data={"username": username, "password": password},
48
+ verify=verify,
49
+ timeout=30,
50
+ )
51
+ except httpx.RequestError as exc:
52
+ raise AuthError(f"Failed to reach Proxmox at {base_url}: {exc}") from exc
53
+
54
+ if resp.status_code != 200:
55
+ msg = resp.json().get("message", resp.text)
56
+ raise AuthError(f"Authentication failed: {msg}")
57
+
58
+ data = resp.json()["data"]
59
+ self._ticket = data["ticket"]
60
+ self._csrf_token = data["CSRFPreventionToken"]
61
+ self._method = AuthMethod.PASSWORD
62
+ self._auth_header = None
63
+
64
+ def set_api_token(self, user: str, token_id: str, secret: str) -> None:
65
+ """Use a Proxmox API token for authentication."""
66
+ raw = f"{user}!{token_id}={secret}"
67
+ encoded = base64.b64encode(raw.encode()).decode()
68
+ self._auth_header = f"PVEAPIToken {encoded}"
69
+ self._method = AuthMethod.API_TOKEN
70
+ self._ticket = None
71
+ self._csrf_token = None
72
+
73
+ def get_headers(self) -> dict[str, str]:
74
+ """Return the HTTP auth headers needed for the current auth method."""
75
+ if self._method == AuthMethod.API_TOKEN:
76
+ return {"Authorization": self._auth_header or ""}
77
+
78
+ if self._method == AuthMethod.PASSWORD:
79
+ headers: dict[str, str] = {}
80
+ if self._ticket:
81
+ headers["Cookie"] = f"PVEAuthCookie={self._ticket}"
82
+ if self._csrf_token:
83
+ headers["CSRFPreventionToken"] = self._csrf_token
84
+ return headers
85
+
86
+ return {}
87
+
88
+ # ------------------------------------------------------------------
89
+ # Helpers
90
+ # ------------------------------------------------------------------
91
+
92
+ @property
93
+ def is_authenticated(self) -> bool:
94
+ return self._method is not None
95
+
96
+ @property
97
+ def method(self) -> AuthMethod | None:
98
+ return self._method
99
+
100
+ def clear(self) -> None:
101
+ """Wipe cached credentials."""
102
+ self._method = None
103
+ self._ticket = None
104
+ self._csrf_token = None
105
+ self._auth_header = None
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ """Serialize for debugging (never exposes secrets)."""
109
+ return {
110
+ "method": self._method.value if self._method else None,
111
+ "has_ticket": self._ticket is not None,
112
+ "has_csrf": self._csrf_token is not None,
113
+ }
@@ -0,0 +1,217 @@
1
+ """HTTP client for the Proxmox VE REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from proxmox.client.auth import AuthManager
11
+ from proxmox.client.exceptions import AuthError, ProxmoxAPIError
12
+
13
+
14
+ class ProxmoxClient:
15
+ """Wraps httpx to talk to the Proxmox VE JSON API.
16
+
17
+ Handles:
18
+ - Base URL construction (prefixes /api2/json)
19
+ - Auth header injection
20
+ - Timeout control
21
+ - TLS verification toggle
22
+ - Retry on 5xx with exponential backoff
23
+ - Dry-run mode
24
+ - CSRF ticket auto-refresh on 401
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ base_url: str,
30
+ auth_manager: AuthManager,
31
+ *,
32
+ timeout: int = 30,
33
+ verify_tls: bool = True,
34
+ dry_run: bool = False,
35
+ retries: int = 3,
36
+ verbose: bool = False,
37
+ ):
38
+ # Normalise trailing slash
39
+ self._base_url = base_url.rstrip("/")
40
+ self._auth = auth_manager
41
+ self._timeout = timeout
42
+ self._verify_tls = verify_tls
43
+ self._dry_run = dry_run
44
+ self._max_retries = retries
45
+ self._verbose = verbose
46
+ self._username: str | None = None
47
+ self._password: str | None = None
48
+
49
+ # ------------------------------------------------------------------
50
+ # Public HTTP methods
51
+ # ------------------------------------------------------------------
52
+
53
+ def request(
54
+ self,
55
+ method: str,
56
+ path: str,
57
+ *,
58
+ params: dict[str, Any] | None = None,
59
+ data: dict[str, Any] | None = None,
60
+ ) -> dict[str, Any] | list[Any]:
61
+ """Send an HTTP request and unwrap the Proxmox JSON envelope.
62
+
63
+ Returns ``response.json()["data"]`` on success.
64
+ """
65
+ path = self._normalise_path(path)
66
+ full_url = f"{self._base_url}/api2/json{path}"
67
+
68
+ self._debug(f"{method} {full_url}")
69
+ if data:
70
+ self._debug(f" body: {data}")
71
+
72
+ if self._dry_run:
73
+ self._print_dry_run(method, full_url, data)
74
+ return {}
75
+
76
+ return self._send_with_retry(method, full_url, params, data)
77
+
78
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any] | list[Any]:
79
+ return self.request("GET", path, params=params)
80
+
81
+ def post(
82
+ self, path: str, *, data: dict[str, Any] | None = None
83
+ ) -> dict[str, Any] | list[Any]:
84
+ return self.request("POST", path, data=data)
85
+
86
+ def put(
87
+ self, path: str, *, data: dict[str, Any] | None = None
88
+ ) -> dict[str, Any] | list[Any]:
89
+ return self.request("PUT", path, data=data)
90
+
91
+ def delete(
92
+ self, path: str, *, params: dict[str, Any] | None = None
93
+ ) -> dict[str, Any] | list[Any]:
94
+ return self.request("DELETE", path, params=params)
95
+
96
+ def set_credentials(self, username: str, password: str) -> None:
97
+ """Store credentials for lazy / auto-refresh authentication."""
98
+ self._username = username
99
+ self._password = password
100
+
101
+ def authenticate(self) -> None:
102
+ """Explicitly authenticate the client (password method)."""
103
+ if self._username and self._password:
104
+ self._auth.authenticate_password(
105
+ self._base_url, self._username, self._password, verify=self._verify_tls
106
+ )
107
+
108
+ # ------------------------------------------------------------------
109
+ # Internals
110
+ # ------------------------------------------------------------------
111
+
112
+ @staticmethod
113
+ def _normalise_path(path: str) -> str:
114
+ if not path.startswith("/"):
115
+ path = "/" + path
116
+ return path
117
+
118
+ def _send_with_retry(
119
+ self,
120
+ method: str,
121
+ url: str,
122
+ params: dict[str, Any] | None,
123
+ data: dict[str, Any] | None,
124
+ ) -> dict[str, Any] | list[Any]:
125
+ last_exc: Exception | None = None
126
+
127
+ for attempt in range(self._max_retries + 1):
128
+ try:
129
+ return self._send_one(method, url, params, data)
130
+ except AuthError:
131
+ raise # don't retry auth errors
132
+ except ProxmoxAPIError as exc:
133
+ if exc.status_code < 500:
134
+ raise
135
+ last_exc = exc
136
+ if attempt < self._max_retries:
137
+ delay = 2**attempt
138
+ self._debug(f" ⏳ retry {attempt + 1}/{self._max_retries} in {delay}s ...")
139
+ time.sleep(delay)
140
+ except httpx.TimeoutException as exc:
141
+ last_exc = exc
142
+ if attempt < self._max_retries:
143
+ delay = 2**attempt
144
+ self._debug(f" ⏳ timeout, retry {attempt + 1}/{self._max_retries} in {delay}s ...")
145
+ time.sleep(delay)
146
+
147
+ if isinstance(last_exc, ProxmoxAPIError):
148
+ raise last_exc
149
+ raise ProxmoxAPIError(0, {"message": str(last_exc)}, url)
150
+
151
+ def _send_one(
152
+ self,
153
+ method: str,
154
+ url: str,
155
+ params: dict[str, Any] | None,
156
+ data: dict[str, Any] | None,
157
+ ) -> dict[str, Any] | list[Any]:
158
+ headers = self._auth.get_headers()
159
+
160
+ try:
161
+ resp = httpx.request(
162
+ method=method,
163
+ url=url,
164
+ params=params,
165
+ data=data,
166
+ headers=headers,
167
+ timeout=self._timeout,
168
+ verify=self._verify_tls,
169
+ )
170
+ except httpx.TimeoutException:
171
+ raise
172
+ except httpx.RequestError as exc:
173
+ # Wrap connection / TLS errors
174
+ msg = str(exc)
175
+ if "SSL" in msg or "certificate" in msg.lower():
176
+ msg += "\nHint: use --insecure to skip TLS verification"
177
+ raise ProxmoxAPIError(0, {"message": msg}, url) from exc
178
+
179
+ self._debug(f" ← {resp.status_code}")
180
+
181
+ if resp.status_code == 401 and self._username and self._password:
182
+ # Auto-refresh ticket once
183
+ self._debug(" 🔄 re-authenticating ...")
184
+ self.authenticate()
185
+ headers = self._auth.get_headers()
186
+ resp = httpx.request(
187
+ method=method,
188
+ url=url,
189
+ params=params,
190
+ data=data,
191
+ headers=headers,
192
+ timeout=self._timeout,
193
+ verify=self._verify_tls,
194
+ )
195
+
196
+ if not (200 <= resp.status_code < 300):
197
+ try:
198
+ body = resp.json()
199
+ except Exception:
200
+ body = {"message": resp.text}
201
+ raise ProxmoxAPIError(resp.status_code, body, url)
202
+
203
+ # Unwrap Proxmox JSON envelope
204
+ envelope = resp.json()
205
+ return envelope.get("data", envelope)
206
+
207
+ def _print_dry_run(self, method: str, url: str, data: dict[str, Any] | None) -> None:
208
+ print(f"{method} {url}")
209
+ print(f"Headers: {self._auth.get_headers()}")
210
+ if data:
211
+ print(f"Body: {data}")
212
+
213
+ def _debug(self, message: str) -> None:
214
+ if self._verbose:
215
+ import sys
216
+
217
+ print(f"[proxmox] {message}", file=sys.stderr)
@@ -0,0 +1,43 @@
1
+ """Proxmox CLI exceptions."""
2
+
3
+
4
+ class ProxmoxError(Exception):
5
+ """Base exception for proxmox CLI errors."""
6
+
7
+ def __init__(self, message: str):
8
+ self.message = message
9
+ super().__init__(message)
10
+
11
+
12
+ class ProxmoxAPIError(ProxmoxError):
13
+ """Raised when the Proxmox API returns a non-2xx response."""
14
+
15
+ def __init__(self, status_code: int, body: dict | None = None, url: str = ""):
16
+ self.status_code = status_code
17
+ self.body = body or {}
18
+ self.url = url
19
+
20
+ # Try to extract a meaningful error message from Proxmox response
21
+ msg = body.get("message", "") if body else ""
22
+ if not msg and isinstance(body, dict) and "errors" in body:
23
+ msg = str(body["errors"])
24
+
25
+ message = f"HTTP {status_code}"
26
+ if url:
27
+ message += f" on {url}"
28
+ if msg:
29
+ message += f": {msg}"
30
+
31
+ super().__init__(message)
32
+
33
+
34
+ class AuthError(ProxmoxError):
35
+ """Raised when authentication fails."""
36
+
37
+
38
+ class ConfigError(ProxmoxError):
39
+ """Raised when configuration is missing or invalid."""
40
+
41
+
42
+ class NotFoundError(ProxmoxAPIError):
43
+ """Raised when a resource is not found (404)."""
File without changes
@@ -0,0 +1,98 @@
1
+ """Config file loader — reads / writes credentials.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import stat
8
+ from pathlib import Path
9
+
10
+ from proxmox.client.exceptions import ConfigError
11
+ from proxmox.config.models import (
12
+ CREDENTIALS_FILE,
13
+ SYSTEM_CONFIG_DIR,
14
+ USER_CONFIG_DIR,
15
+ Credentials,
16
+ )
17
+
18
+
19
+ class ConfigLoader:
20
+ """Loads and persists credentials to a JSON config file.
21
+
22
+ Priority:
23
+ 1. ~/.config/proxmox-cli/credentials.json (user)
24
+ 2. /etc/proxmox-cli/credentials.json (system)
25
+ """
26
+
27
+ def __init__(self, user_dir: Path | None = None, system_dir: Path | None = None):
28
+ self._user_dir = user_dir or USER_CONFIG_DIR
29
+ self._system_dir = system_dir or SYSTEM_CONFIG_DIR
30
+
31
+ # ------------------------------------------------------------------
32
+ # Public API
33
+ # ------------------------------------------------------------------
34
+
35
+ def load(self) -> Credentials:
36
+ """Load credentials from disk. Raises ConfigError if not found."""
37
+ path = self._find_file()
38
+ if path is None:
39
+ raise ConfigError(
40
+ "No credentials found. Run 'proxmox auth login' first, "
41
+ f"or place a {CREDENTIALS_FILE} in {self._user_dir} or {self._system_dir}."
42
+ )
43
+ return self._read(path)
44
+
45
+ def load_or_none(self) -> Credentials | None:
46
+ """Load credentials, returning None if not found."""
47
+ try:
48
+ return self.load()
49
+ except ConfigError:
50
+ return None
51
+
52
+ def save(self, credentials: Credentials) -> Path:
53
+ """Persist credentials to the user config directory.
54
+
55
+ Creates parent directories and sets restrictive permissions (0o600).
56
+ """
57
+ self._user_dir.mkdir(parents=True, exist_ok=True)
58
+ path = self._user_dir / CREDENTIALS_FILE
59
+
60
+ data = credentials.model_dump(mode="json", exclude_none=True)
61
+ payload = json.dumps(data, indent=2)
62
+ path.write_text(payload)
63
+
64
+ # Restrict permissions: owner-read/write only
65
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
66
+ return path
67
+
68
+ def clear(self) -> None:
69
+ """Delete the user-level credentials file."""
70
+ path = self._user_dir / CREDENTIALS_FILE
71
+ if path.exists():
72
+ path.unlink()
73
+
74
+ def exists(self) -> bool:
75
+ """Return True if a credentials file can be found."""
76
+ return self._find_file() is not None
77
+
78
+ # ------------------------------------------------------------------
79
+ # Internals
80
+ # ------------------------------------------------------------------
81
+
82
+ def _find_file(self) -> Path | None:
83
+ for base in (self._user_dir, self._system_dir):
84
+ p = base / CREDENTIALS_FILE
85
+ if p.exists():
86
+ return p
87
+ return None
88
+
89
+ def _read(self, path: Path) -> Credentials:
90
+ try:
91
+ raw = json.loads(path.read_text())
92
+ except json.JSONDecodeError as exc:
93
+ raise ConfigError(f"Invalid JSON in {path}: {exc}") from exc
94
+
95
+ try:
96
+ return Credentials(**raw)
97
+ except Exception as exc:
98
+ raise ConfigError(f"Invalid credentials in {path}: {exc}") from exc
@@ -0,0 +1,51 @@
1
+ """Pydantic models for configuration and credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, Field, field_validator, model_validator
9
+
10
+
11
+ class AuthMethod(str, Enum):
12
+ PASSWORD = "password"
13
+ API_TOKEN = "api_token"
14
+
15
+
16
+ class Credentials(BaseModel):
17
+ """Persisted credentials for a single Proxmox endpoint."""
18
+
19
+ url: str = Field(..., description="Proxmox API URL, e.g. https://192.168.1.10:8006")
20
+ username: str = Field(..., description="Username, e.g. root@pam")
21
+ auth_method: AuthMethod = Field(..., description="Authentication method")
22
+ password: str | None = Field(None, description="Password (for password auth)")
23
+ api_token_id: str | None = Field(None, description="Token ID (for token auth)")
24
+ api_token_secret: str | None = Field(None, description="Token secret (for token auth)")
25
+ verify_tls: bool = Field(True, description="Whether to verify TLS certificates")
26
+
27
+ @field_validator("url")
28
+ @classmethod
29
+ def url_must_have_scheme(cls, v: str) -> str:
30
+ if not v.startswith(("http://", "https://")):
31
+ raise ValueError("URL must start with http:// or https://")
32
+ return v.rstrip("/")
33
+
34
+ @model_validator(mode="after")
35
+ def validate_auth_fields(self):
36
+ if self.auth_method == AuthMethod.PASSWORD and not self.password:
37
+ raise ValueError("Password is required for password authentication")
38
+ if self.auth_method == AuthMethod.API_TOKEN:
39
+ if not self.api_token_id:
40
+ raise ValueError("API token ID is required for token authentication")
41
+ if not self.api_token_secret:
42
+ raise ValueError("API token secret is required for token authentication")
43
+ return self
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Config file paths (XDG-compliant)
48
+ # ---------------------------------------------------------------------------
49
+ USER_CONFIG_DIR = Path.home() / ".config" / "proxmox-cli"
50
+ SYSTEM_CONFIG_DIR = Path("/etc/proxmox-cli")
51
+ CREDENTIALS_FILE = "credentials.json"
File without changes
@@ -0,0 +1,26 @@
1
+ """Output formatter dispatcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from proxmox.output.json_fmt import format_json
8
+ from proxmox.output.table_fmt import format_table
9
+ from proxmox.output.yaml_fmt import format_yaml
10
+
11
+
12
+ def format_output(data: Any, fmt: str, *, columns: list[str] | None = None) -> str:
13
+ """Dispatch to the appropriate formatter.
14
+
15
+ Args:
16
+ data: The data to format (dict, list, etc.)
17
+ fmt: One of 'json', 'table', 'yaml'
18
+ columns: Optional column name override for table mode.
19
+ """
20
+ if fmt == "json":
21
+ return format_json(data)
22
+ if fmt == "table":
23
+ return format_table(data, columns)
24
+ if fmt == "yaml":
25
+ return format_yaml(data)
26
+ return format_json(data)
@@ -0,0 +1,11 @@
1
+ """JSON output formatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def format_json(data: Any, indent: int = 2) -> str:
10
+ """Render data as pretty-printed JSON."""
11
+ return json.dumps(data, indent=indent, ensure_ascii=False, default=str)
@@ -0,0 +1,64 @@
1
+ """Table output formatter using rich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+
11
+ def format_table(data: Any, columns: list[str] | None = None) -> str:
12
+ """Render data as a rich-powered ASCII table.
13
+
14
+ - list[dict] → columnar table
15
+ - dict → key-value table
16
+ - other → plain string
17
+ """
18
+ console = Console(force_terminal=True, color_system=None, width=120)
19
+ table = _build_table(data, columns)
20
+ with console.capture() as capture:
21
+ console.print(table)
22
+ return capture.get().rstrip()
23
+
24
+
25
+ def _build_table(data: Any, columns: list[str] | None) -> Table:
26
+ if isinstance(data, list):
27
+ return _list_table(data, columns)
28
+ if isinstance(data, dict):
29
+ return _kv_table(data)
30
+ return _plain_table(data)
31
+
32
+
33
+ def _list_table(items: list[dict], columns: list[str] | None) -> Table:
34
+ if not items:
35
+ return Table(title="No results")
36
+
37
+ # Auto-detect columns from first item keys if not specified
38
+ cols = columns or list(items[0].keys())
39
+ table = Table(show_header=True, header_style="bold")
40
+ for col in cols:
41
+ table.add_column(col, overflow="fold")
42
+ for item in items:
43
+ table.add_row(*[str(item.get(col, "")) for col in cols])
44
+ return table
45
+
46
+
47
+ def _kv_table(data: dict) -> Table:
48
+ table = Table(show_header=True, header_style="bold")
49
+ table.add_column("Key", style="dim")
50
+ table.add_column("Value")
51
+ for key, value in data.items():
52
+ if isinstance(value, (dict, list)):
53
+ import json
54
+
55
+ value = json.dumps(value, default=str)
56
+ table.add_row(str(key), str(value))
57
+ return table
58
+
59
+
60
+ def _plain_table(data: Any) -> Table:
61
+ table = Table()
62
+ table.add_column("Result")
63
+ table.add_row(str(data))
64
+ return table
@@ -0,0 +1,12 @@
1
+ """YAML output formatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ def format_yaml(data: Any) -> str:
11
+ """Render data as YAML."""
12
+ return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)
File without changes
@@ -0,0 +1,14 @@
1
+ """Shared helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def vmid_type(value: str) -> int:
7
+ """Argparse type for validating VMID (positive integer)."""
8
+ try:
9
+ v = int(value)
10
+ except ValueError:
11
+ raise ValueError(f"Invalid VMID '{value}': must be an integer")
12
+ if v <= 0:
13
+ raise ValueError(f"Invalid VMID '{value}': must be positive")
14
+ return v
@@ -0,0 +1,15 @@
1
+ """Structured stderr logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def log_error(message: str) -> None:
9
+ """Print an error message to stderr."""
10
+ print(f"Error: {message}", file=sys.stderr)
11
+
12
+
13
+ def log_info(message: str) -> None:
14
+ """Print an info message to stderr."""
15
+ print(message, file=sys.stderr)