Skip to content

Commit 0431463

Browse files
committed
feat(vmm-cli): send a bearer token and reject half-set Basic credentials
1 parent 09f788c commit 0431463

1 file changed

Lines changed: 48 additions & 13 deletions

File tree

dstack/vmm/src/vmm-cli.py

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ def load_config() -> Dict[str, Any]:
6363
"""Load configuration from the default config file.
6464
6565
Returns:
66-
Dictionary with configuration values (url, auth_user, auth_password)
66+
Dictionary with configuration values (url, auth_user, auth_password,
67+
auth_token)
6768
6869
"""
6970
if not os.path.exists(DEFAULT_CONFIG_PATH):
@@ -371,12 +372,29 @@ def __init__(
371372
base_url: str,
372373
auth_user: Optional[str] = None,
373374
auth_password: Optional[str] = None,
375+
auth_token: Optional[str] = None,
374376
):
375-
"""Initialize the client with a base URL and optional auth credentials."""
377+
"""Initialize the client with a base URL and optional auth credentials.
378+
379+
Two credential forms are supported for a VMM with `[auth]` enabled:
380+
a bearer token (sent as `Authorization: Bearer <token>`), or HTTP Basic
381+
(user + password). A bearer token takes precedence when both are set.
382+
"""
376383
self.base_url = base_url.rstrip("/")
377384
self.use_uds = self.base_url.startswith("unix:")
378-
self.auth_user = auth_user
379-
self.auth_password = auth_password
385+
self.auth_user = auth_user or None
386+
self.auth_password = auth_password or None
387+
self.auth_token = auth_token or None
388+
# fail fast on a half-configured Basic credential: silently sending no
389+
# auth header would make requests fail against `[auth] enabled = true`
390+
# in a way that is hard to diagnose.
391+
if not self.auth_token and bool(self.auth_user) != bool(self.auth_password):
392+
missing = "password" if self.auth_user else "username"
393+
raise ValueError(
394+
f"incomplete VMM Basic auth credentials: {missing} is missing. "
395+
"set both username and password, or use a bearer token "
396+
"(--token / DSTACK_VMM_TOKEN)."
397+
)
380398

381399
if self.use_uds:
382400
self.uds_path = self.base_url[5:] # Remove 'unix:' prefix
@@ -410,13 +428,17 @@ def request(
410428
if headers is None:
411429
headers = {}
412430

413-
# Add Basic Authentication header if credentials are provided
414-
if self.auth_user and self.auth_password:
415-
credentials = f"{self.auth_user}:{self.auth_password}"
416-
encoded_credentials = base64.b64encode(credentials.encode("utf-8")).decode(
417-
"ascii"
418-
)
419-
headers["Authorization"] = f"Basic {encoded_credentials}"
431+
# Add an auth header when credentials are provided. A bearer token wins
432+
# over Basic when both happen to be set.
433+
if "Authorization" not in headers:
434+
if self.auth_token:
435+
headers["Authorization"] = f"Bearer {self.auth_token}"
436+
elif self.auth_user and self.auth_password:
437+
credentials = f"{self.auth_user}:{self.auth_password}"
438+
encoded_credentials = base64.b64encode(
439+
credentials.encode("utf-8")
440+
).decode("ascii")
441+
headers["Authorization"] = f"Basic {encoded_credentials}"
420442

421443
# Prepare the body
422444
if isinstance(body, dict):
@@ -480,11 +502,12 @@ def __init__(
480502
base_url: str,
481503
auth_user: Optional[str] = None,
482504
auth_password: Optional[str] = None,
505+
auth_token: Optional[str] = None,
483506
):
484507
"""Initialize the CLI with a base URL and optional auth credentials."""
485508
self.base_url = base_url.rstrip("/")
486509
self.headers = {"Content-Type": "application/json"}
487-
self.client = VmmClient(base_url, auth_user, auth_password)
510+
self.client = VmmClient(base_url, auth_user, auth_password, auth_token)
488511

489512
def rpc_call(self, method: str, params: Optional[Dict] = None) -> Dict:
490513
"""Make an RPC call to the dstack-vmm API."""
@@ -1519,6 +1542,7 @@ def main():
15191542
default_auth_password = os.environ.get(
15201543
"DSTACK_VMM_AUTH_PASSWORD", config.get("auth_password")
15211544
)
1545+
default_auth_token = os.environ.get("DSTACK_VMM_TOKEN", config.get("auth_token"))
15221546

15231547
parser.add_argument(
15241548
"--url",
@@ -1537,6 +1561,13 @@ def main():
15371561
default=default_auth_password,
15381562
help="Basic auth password (can also be set via DSTACK_VMM_AUTH_PASSWORD env var or config file)",
15391563
)
1564+
parser.add_argument(
1565+
"--token",
1566+
default=default_auth_token,
1567+
help="bearer token for a VMM with `[auth]` enabled, sent as "
1568+
"`Authorization: Bearer` (can also be set via DSTACK_VMM_TOKEN env var "
1569+
"or config file). Takes precedence over --auth-user/--auth-password.",
1570+
)
15401571

15411572
subparsers = parser.add_subparsers(dest="command", help="Commands")
15421573

@@ -1939,7 +1970,11 @@ def _patched_format_help():
19391970

19401971
# Resolve the URL with auto-discovery
19411972
url = resolve_vmm_url(instances, config, args.url)
1942-
cli = VmmCLI(url, args.auth_user, args.auth_password)
1973+
try:
1974+
cli = VmmCLI(url, args.auth_user, args.auth_password, args.token)
1975+
except ValueError as e:
1976+
print(f"error: {e}", file=sys.stderr)
1977+
sys.exit(1)
19431978

19441979
if args.command == "lsvm":
19451980
cli.list_vms(args.verbose, args.json)

0 commit comments

Comments
 (0)