godfather-cli 2.1.0__tar.gz → 2.2.0__tar.gz

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.
Files changed (25) hide show
  1. {godfather_cli-2.1.0/godfather_cli.egg-info → godfather_cli-2.2.0}/PKG-INFO +22 -14
  2. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/README.md +21 -13
  3. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/__init__.py +1 -1
  4. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/auth.py +105 -12
  5. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/cli.py +84 -33
  6. godfather_cli-2.2.0/godfather_cli/login_server.py +72 -0
  7. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/picker.py +18 -13
  8. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/pod_manager.py +111 -14
  9. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/ssh_connector.py +18 -10
  10. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/update_checker.py +18 -20
  11. {godfather_cli-2.1.0 → godfather_cli-2.2.0/godfather_cli.egg-info}/PKG-INFO +22 -14
  12. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli.egg-info/SOURCES.txt +4 -1
  13. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/pyproject.toml +14 -2
  14. godfather_cli-2.2.0/tests/test_cli.py +262 -0
  15. godfather_cli-2.2.0/tests/test_login.py +151 -0
  16. godfather_cli-2.2.0/tests/test_sessions.py +109 -0
  17. godfather_cli-2.1.0/tests/test_cli.py +0 -102
  18. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/LICENSE +0 -0
  19. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/MANIFEST.in +0 -0
  20. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/ui.py +1 -1
  21. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli.egg-info/dependency_links.txt +0 -0
  22. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli.egg-info/entry_points.txt +0 -0
  23. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli.egg-info/requires.txt +0 -0
  24. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli.egg-info/top_level.txt +0 -0
  25. {godfather_cli-2.1.0 → godfather_cli-2.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godfather-cli
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Free compute for ASU students: connect to AI Society at ASU GPU pods
5
5
  Author-email: AI Society ASU <theaisocietyasu@gmail.com>
6
6
  License: MIT
@@ -62,10 +62,13 @@ pip install -e .
62
62
 
63
63
  1. Run `godfather` with no arguments. On first run you won't be logged in
64
64
  yet, so it'll walk you into the login flow.
65
- 2. It opens the AI Society platform's sign-in page in your browser (and
66
- prints the link). Sign in with Discord and copy the token shown there.
67
- 3. Paste the token back into the terminal. The CLI checks it with the
68
- platform and stores it in `~/.godfather/config.json`.
65
+ 2. It opens the sign-in page in your browser and prints the link. Sign in
66
+ with Discord. The browser sends the login back to the CLI, which checks it
67
+ and stores the token in `~/.godfather/config.json`.
68
+ 3. If the browser cannot reach the CLI (no browser on this computer, an
69
+ older server, or no sign-in within 5 minutes), the CLI asks you to paste
70
+ the token that the sign-in page shows. `godfather auth --paste` goes
71
+ straight to that, for a browser on another computer.
69
72
  4. From the menu (or `godfather connect`), pick a pod. The CLI creates an SSH
70
73
  key on your machine the first time, asks the server for a 12-hour
71
74
  certificate for that pod, and opens the connection.
@@ -81,12 +84,14 @@ Windows 10+ ship it.
81
84
  ### Interactive menu
82
85
 
83
86
  Running `godfather` with no arguments opens a menu. Use the arrow keys to
84
- choose Connect to a pod, List pods, Status, Log out or Exit, and press Enter.
85
- Ctrl-C quits.
87
+ choose Connect to a pod, List pods, Sessions, Status, Log out or Exit, and
88
+ press Enter. Ctrl-C quits.
86
89
 
87
90
  Connect to a pod shows a list of your pods with their status, access and
88
91
  pod ID. A pod that is not running shows with the reason and cannot be
89
92
  chosen. If only one pod is running, the CLI asks for a yes instead.
93
+ If none of your pods is running, the CLI shows the next session on your
94
+ pods. A pod starts 10 minutes before its session.
90
95
 
91
96
  When input is not a terminal (for example in a script), the menu and the pod
92
97
  list use numbered prompts.
@@ -97,8 +102,11 @@ list use numbered prompts.
97
102
  godfather list # List pods you can connect to
98
103
  godfather connect # Connect to a pod, picking from a list
99
104
  godfather connect <pod-id> # Connect to a specific pod
105
+ godfather connect --admin # Ask for a root shell (officers, when pod admin is on)
106
+ godfather sessions # Upcoming sessions on your pods, in local time
100
107
  godfather status # Show server, org, login, token expiry and version
101
- godfather auth # Log in, or refresh an expired session
108
+ godfather auth # Log in with Discord in your browser
109
+ godfather auth --paste # Log in by pasting the token
102
110
  godfather logout # Clear the stored session
103
111
  godfather update # Update the CLI to the latest version
104
112
 
@@ -141,23 +149,23 @@ another server or org; the ones you last logged in with are remembered.
141
149
  ## Development
142
150
 
143
151
  The code is in `godfather_cli/`: `cli.py` (commands and menu), `picker.py` (arrow-key menu and pod picker), `auth.py`
144
- (platform sign-in), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
152
+ (platform sign-in), `login_server.py` (the sign-in callback), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
145
153
  certificate, running ssh), `update_checker.py` (PyPI version check), `ui.py`
146
154
  (shared console styling). Tests are in `tests/`:
147
155
 
148
156
  ```bash
149
- pip install -e . pytest
150
- pytest -q tests
157
+ uv sync --group dev
158
+ uv run pytest -q tests
151
159
  ```
152
160
 
161
+ `make check-cli` at the repo root runs ruff, the tests and a build, as CI does.
162
+
153
163
  Releases go to PyPI when a version bump is merged to main; see the
154
164
  repo README.
155
165
 
156
166
  ## Contributing
157
167
 
158
- 1. Fork the repository.
159
- 2. Create a feature branch (`git checkout -b feature/your-feature`).
160
- 3. Commit your changes and open a Pull Request.
168
+ See [CONTRIBUTING.md](../CONTRIBUTING.md).
161
169
 
162
170
  ## License
163
171
 
@@ -33,10 +33,13 @@ pip install -e .
33
33
 
34
34
  1. Run `godfather` with no arguments. On first run you won't be logged in
35
35
  yet, so it'll walk you into the login flow.
36
- 2. It opens the AI Society platform's sign-in page in your browser (and
37
- prints the link). Sign in with Discord and copy the token shown there.
38
- 3. Paste the token back into the terminal. The CLI checks it with the
39
- platform and stores it in `~/.godfather/config.json`.
36
+ 2. It opens the sign-in page in your browser and prints the link. Sign in
37
+ with Discord. The browser sends the login back to the CLI, which checks it
38
+ and stores the token in `~/.godfather/config.json`.
39
+ 3. If the browser cannot reach the CLI (no browser on this computer, an
40
+ older server, or no sign-in within 5 minutes), the CLI asks you to paste
41
+ the token that the sign-in page shows. `godfather auth --paste` goes
42
+ straight to that, for a browser on another computer.
40
43
  4. From the menu (or `godfather connect`), pick a pod. The CLI creates an SSH
41
44
  key on your machine the first time, asks the server for a 12-hour
42
45
  certificate for that pod, and opens the connection.
@@ -52,12 +55,14 @@ Windows 10+ ship it.
52
55
  ### Interactive menu
53
56
 
54
57
  Running `godfather` with no arguments opens a menu. Use the arrow keys to
55
- choose Connect to a pod, List pods, Status, Log out or Exit, and press Enter.
56
- Ctrl-C quits.
58
+ choose Connect to a pod, List pods, Sessions, Status, Log out or Exit, and
59
+ press Enter. Ctrl-C quits.
57
60
 
58
61
  Connect to a pod shows a list of your pods with their status, access and
59
62
  pod ID. A pod that is not running shows with the reason and cannot be
60
63
  chosen. If only one pod is running, the CLI asks for a yes instead.
64
+ If none of your pods is running, the CLI shows the next session on your
65
+ pods. A pod starts 10 minutes before its session.
61
66
 
62
67
  When input is not a terminal (for example in a script), the menu and the pod
63
68
  list use numbered prompts.
@@ -68,8 +73,11 @@ list use numbered prompts.
68
73
  godfather list # List pods you can connect to
69
74
  godfather connect # Connect to a pod, picking from a list
70
75
  godfather connect <pod-id> # Connect to a specific pod
76
+ godfather connect --admin # Ask for a root shell (officers, when pod admin is on)
77
+ godfather sessions # Upcoming sessions on your pods, in local time
71
78
  godfather status # Show server, org, login, token expiry and version
72
- godfather auth # Log in, or refresh an expired session
79
+ godfather auth # Log in with Discord in your browser
80
+ godfather auth --paste # Log in by pasting the token
73
81
  godfather logout # Clear the stored session
74
82
  godfather update # Update the CLI to the latest version
75
83
 
@@ -112,23 +120,23 @@ another server or org; the ones you last logged in with are remembered.
112
120
  ## Development
113
121
 
114
122
  The code is in `godfather_cli/`: `cli.py` (commands and menu), `picker.py` (arrow-key menu and pod picker), `auth.py`
115
- (platform sign-in), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
123
+ (platform sign-in), `login_server.py` (the sign-in callback), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
116
124
  certificate, running ssh), `update_checker.py` (PyPI version check), `ui.py`
117
125
  (shared console styling). Tests are in `tests/`:
118
126
 
119
127
  ```bash
120
- pip install -e . pytest
121
- pytest -q tests
128
+ uv sync --group dev
129
+ uv run pytest -q tests
122
130
  ```
123
131
 
132
+ `make check-cli` at the repo root runs ruff, the tests and a build, as CI does.
133
+
124
134
  Releases go to PyPI when a version bump is merged to main; see the
125
135
  repo README.
126
136
 
127
137
  ## Contributing
128
138
 
129
- 1. Fork the repository.
130
- 2. Create a feature branch (`git checkout -b feature/your-feature`).
131
- 3. Commit your changes and open a Pull Request.
139
+ See [CONTRIBUTING.md](../CONTRIBUTING.md).
132
140
 
133
141
  ## License
134
142
 
@@ -2,6 +2,6 @@
2
2
  Godfather CLI package
3
3
  """
4
4
 
5
- __version__ = "2.1.0"
5
+ __version__ = "2.2.0"
6
6
  __author__ = "AI Society ASU"
7
7
  __email__ = "admin@ais-asu.com"
@@ -1,21 +1,30 @@
1
1
  """CLI authentication: token storage, login flow, and token verification."""
2
2
 
3
+ import base64
3
4
  import datetime
5
+ import hashlib
4
6
  import json
5
7
  import os
6
- import requests
8
+ import secrets
7
9
  import webbrowser
8
10
  from pathlib import Path
9
11
  from typing import Dict, Optional
12
+ from urllib.parse import urlencode
13
+
14
+ import requests
10
15
  from rich.panel import Panel
11
16
  from rich.prompt import Prompt
12
17
 
13
- from .ui import console, success, error, warning, spinner, BOX, BORDER
18
+ from .login_server import LoopbackReceiver
19
+ from .ui import BORDER, BOX, console, error, spinner, success, warning
14
20
 
15
21
  TOKEN_PREFIX = 'plat_'
16
22
  # The platform issues CLI tokens for 90 days.
17
23
  TOKEN_DAYS = 90
18
24
 
25
+ # How long the CLI waits for the browser sign-in.
26
+ LOGIN_WAIT_SECONDS = 300
27
+
19
28
  # Token states that check_token returns.
20
29
  TOKEN_OK = 'ok'
21
30
  TOKEN_MISSING = 'missing'
@@ -24,6 +33,17 @@ TOKEN_UNREACHABLE = 'unreachable'
24
33
  TOKEN_REFUSED = 'refused'
25
34
 
26
35
 
36
+ def make_verifier() -> str:
37
+ """A PKCE code verifier (RFC 7636): 64 URL-safe characters."""
38
+ return secrets.token_urlsafe(48)
39
+
40
+
41
+ def challenge_for(verifier: str) -> str:
42
+ """The S256 code challenge of a verifier: base64url SHA-256 without padding."""
43
+ digest = hashlib.sha256(verifier.encode('ascii')).digest()
44
+ return base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
45
+
46
+
27
47
  class CLIAuthenticator:
28
48
  """Handle CLI authentication."""
29
49
 
@@ -61,8 +81,84 @@ class CLIAuthenticator:
61
81
  """URL of a platform compute route for the configured org."""
62
82
  return f"{self.api_base}/api/compute/{self.org}{path}"
63
83
 
64
- def authenticate(self) -> bool:
65
- """Walk the user through signing in with Discord and pasting the token it shows."""
84
+ def authenticate(self, paste: bool = False) -> bool:
85
+ """Sign in with Discord. The browser hands the token back; the paste flow is the fallback."""
86
+ if not paste:
87
+ if self.supports_loopback():
88
+ token = self._browser_login()
89
+ if token:
90
+ return self._verify_and_save(token)
91
+ else:
92
+ console.print("[dim]This server does not support browser sign-in yet. Paste the token instead.[/dim]")
93
+ return self._paste_login()
94
+
95
+ def supports_loopback(self) -> bool:
96
+ """True when the server has the cli/token route. It answers 400 to an empty body."""
97
+ try:
98
+ response = requests.post(self.compute_url('/cli/token'), json={}, timeout=10)
99
+ except requests.RequestException:
100
+ return False
101
+ return response.status_code == 400
102
+
103
+ def _browser_login(self) -> Optional[str]:
104
+ """The token from the localhost callback with PKCE, or None after printing why not."""
105
+ state = secrets.token_urlsafe(16)
106
+ verifier = make_verifier()
107
+ try:
108
+ receiver = LoopbackReceiver(state)
109
+ except OSError:
110
+ console.print("[dim]Could not listen on 127.0.0.1. Paste the token instead.[/dim]")
111
+ return None
112
+ try:
113
+ query = urlencode(
114
+ {
115
+ 'port': receiver.port,
116
+ 'state': state,
117
+ 'code_challenge': challenge_for(verifier),
118
+ 'code_challenge_method': 'S256',
119
+ }
120
+ )
121
+ login_url = f"{self.compute_url('/cli/login')}?{query}"
122
+ try:
123
+ opened = webbrowser.open(login_url)
124
+ except webbrowser.Error:
125
+ opened = False
126
+ if not opened:
127
+ console.print("[dim]No browser opened on this computer. Paste the token instead.[/dim]")
128
+ return None
129
+ console.print(f"Sign in with Discord in your browser. If it did not open, go to:\n{login_url}")
130
+ try:
131
+ with spinner("Waiting for the browser sign-in..."):
132
+ code = receiver.wait(LOGIN_WAIT_SECONDS)
133
+ except KeyboardInterrupt:
134
+ console.print("[dim]Cancelled[/dim]")
135
+ return None
136
+ finally:
137
+ receiver.close()
138
+ if not code:
139
+ console.print("[dim]The browser sign-in did not finish in time. Paste the token instead.[/dim]")
140
+ return None
141
+ return self._redeem(code, verifier)
142
+
143
+ def _redeem(self, code: str, verifier: str) -> Optional[str]:
144
+ """Exchange the one-time code and the PKCE verifier for a token."""
145
+ try:
146
+ with spinner("Getting your token..."):
147
+ response = requests.post(
148
+ self.compute_url('/cli/token'),
149
+ json={'code': code, 'code_verifier': verifier},
150
+ timeout=10,
151
+ )
152
+ token = str(response.json().get('token') or '') if response.status_code == 200 else ''
153
+ except (requests.RequestException, ValueError, AttributeError):
154
+ token = ''
155
+ if not token.startswith(TOKEN_PREFIX):
156
+ console.print("[dim]The server did not return a token. Paste the token instead.[/dim]")
157
+ return None
158
+ return token
159
+
160
+ def _paste_login(self) -> bool:
161
+ """Open the sign-in page and take the token the member pastes."""
66
162
  login_url = self.compute_url('/cli/login')
67
163
  panel = Panel(
68
164
  f"[bold]1.[/bold] Open [bold]{login_url}[/bold] and sign in with Discord\n"
@@ -91,13 +187,14 @@ class CLIAuthenticator:
91
187
  error("That doesn't look like a valid Godfather token")
92
188
  console.print(f"[dim]Tokens start with {TOKEN_PREFIX}. Get a fresh one from {login_url}[/dim]")
93
189
  return False
190
+ return self._verify_and_save(token)
94
191
 
192
+ def _verify_and_save(self, token: str) -> bool:
193
+ """Check the token with GET /me/pods, then save it with the server and org."""
95
194
  try:
96
195
  with spinner("Verifying token..."):
97
196
  response = requests.get(
98
- self.compute_url('/me/pods'),
99
- headers={'Authorization': f'Bearer {token}'},
100
- timeout=10
197
+ self.compute_url('/me/pods'), headers={'Authorization': f'Bearer {token}'}, timeout=10
101
198
  )
102
199
  except requests.ConnectionError:
103
200
  error(f"Couldn't reach {self.api_base}")
@@ -153,11 +250,7 @@ class CLIAuthenticator:
153
250
  return TOKEN_MISSING
154
251
 
155
252
  try:
156
- response = requests.get(
157
- self.compute_url('/me/pods'),
158
- headers=self.auth_headers(),
159
- timeout=5
160
- )
253
+ response = requests.get(self.compute_url('/me/pods'), headers=self.auth_headers(), timeout=5)
161
254
  except requests.RequestException:
162
255
  return TOKEN_UNREACHABLE
163
256
  if response.status_code == 200:
@@ -8,19 +8,24 @@ from pathlib import Path
8
8
  from typing import Dict, List, Optional
9
9
 
10
10
  from rich.panel import Panel
11
- from rich.table import Table
12
11
  from rich.prompt import Prompt
12
+ from rich.table import Table
13
13
 
14
- from . import picker
14
+ from . import __version__, picker
15
15
  from .auth import (
16
- CLIAuthenticator, TOKEN_DAYS, TOKEN_EXPIRED, TOKEN_MISSING, TOKEN_OK, TOKEN_REFUSED, TOKEN_UNREACHABLE,
16
+ TOKEN_DAYS,
17
+ TOKEN_EXPIRED,
18
+ TOKEN_MISSING,
19
+ TOKEN_OK,
20
+ TOKEN_REFUSED,
21
+ TOKEN_UNREACHABLE,
22
+ CLIAuthenticator,
17
23
  )
18
24
  from .picker import ask_menu, ask_pod, ask_yes, connectable, is_interactive
19
- from .pod_manager import ApiError, EXPIRED, PodManager, report
25
+ from .pod_manager import EXPIRED, ApiError, PodManager, report
20
26
  from .ssh_connector import SSHConnector
21
- from .update_checker import check_for_updates, show_update_warning, perform_update
22
- from .ui import console, error, warning, info, spinner, BOX, BORDER, PURPLE
23
- from . import __version__
27
+ from .ui import BORDER, BOX, PURPLE, console, error, info, spinner, warning
28
+ from .update_checker import check_for_updates, perform_update, show_update_warning
24
29
 
25
30
  # The AI Society platform API and org. Override with --api-url/--org or GODFATHER_API_URL/GODFATHER_ORG.
26
31
  DEFAULT_API_URL = 'https://zs6k5wi0boaaax-8000.proxy.runpod.net'
@@ -60,7 +65,8 @@ class GodfatherCLI:
60
65
 
61
66
  def print_banner(self):
62
67
  banner = Panel.fit(
63
- f"[bold {PURPLE}]Godfather CLI[/bold {PURPLE}]\n[dim]Free compute from AI Society at ASU v{__version__}[/dim]",
68
+ f"[bold {PURPLE}]Godfather CLI[/bold {PURPLE}]\n"
69
+ f"[dim]Free compute from AI Society at ASU v{__version__}[/dim]",
64
70
  border_style=BORDER,
65
71
  box=BOX,
66
72
  )
@@ -126,6 +132,36 @@ class GodfatherCLI:
126
132
  pods = self.load_pods()
127
133
  if pods is not None:
128
134
  self.pod_manager.show_pods(pods)
135
+ if not pods:
136
+ self.print_next_session()
137
+
138
+ def list_sessions(self):
139
+ """Print the upcoming sessions on the member's pods."""
140
+ if not self.ensure_authenticated():
141
+ return
142
+ try:
143
+ with spinner("Fetching sessions..."):
144
+ sessions = self.pod_manager.fetch_sessions()
145
+ except ApiError as e:
146
+ report(e)
147
+ return
148
+ if sessions is None:
149
+ info("This server does not list sessions yet.")
150
+ elif not sessions:
151
+ info("No upcoming sessions on your pods.")
152
+ else:
153
+ console.print(self.pod_manager.sessions_table(sessions))
154
+ console.print("[dim]A pod starts 10 minutes before its session.[/dim]")
155
+
156
+ def print_next_session(self) -> None:
157
+ """Print the soonest session on the member's pods. Prints nothing when there is none or on an error."""
158
+ try:
159
+ sessions = self.pod_manager.fetch_sessions()
160
+ except ApiError:
161
+ return
162
+ line = self.pod_manager.next_session_line(sessions or [])
163
+ if line:
164
+ console.print(line)
129
165
 
130
166
  def choose_pod(self) -> Optional[str]:
131
167
  """Fetch pods and let the user pick one. None when there is none or the user cancels."""
@@ -134,12 +170,14 @@ class GodfatherCLI:
134
170
  return None
135
171
  if not pods:
136
172
  self.pod_manager.show_no_pods()
173
+ self.print_next_session()
137
174
  return None
138
175
  if not is_interactive():
139
176
  return self.pod_manager.prompt_pod_number(pods)
140
177
  if not connectable(pods):
141
178
  error("None of your pods are running.")
142
179
  console.print("[dim]Ask an officer to start one, then run 'godfather connect' again.[/dim]")
180
+ self.print_next_session()
143
181
  return None
144
182
  try:
145
183
  return ask_pod(pods)
@@ -147,7 +185,7 @@ class GodfatherCLI:
147
185
  console.print("[dim]Cancelled[/dim]")
148
186
  return None
149
187
 
150
- def connect_to_pod(self, pod_id: str = None):
188
+ def connect_to_pod(self, pod_id: str = None, admin: bool = False):
151
189
  if not self.ensure_authenticated():
152
190
  return
153
191
 
@@ -164,7 +202,7 @@ class GodfatherCLI:
164
202
  for attempt in range(2):
165
203
  try:
166
204
  with spinner(f"Getting a certificate for pod {pod_id[:12]}..."):
167
- ssh_info = self.pod_manager.fetch_connection_info(pod_id, public_key)
205
+ ssh_info = self.pod_manager.fetch_connection_info(pod_id, public_key, admin=admin)
168
206
  break
169
207
  except ApiError as e:
170
208
  if e.kind == EXPIRED and attempt == 0:
@@ -178,6 +216,8 @@ class GodfatherCLI:
178
216
 
179
217
  if not self.ssh_connector.save_certificate(ssh_info.get('certificate', '')):
180
218
  return
219
+ if admin and not ssh_info.get('is_admin'):
220
+ warning("The server gave you a member shell, not root.")
181
221
 
182
222
  self.ssh_connector.connect(ssh_info)
183
223
 
@@ -231,8 +271,8 @@ class GodfatherCLI:
231
271
  def logout(self):
232
272
  self.authenticator.logout()
233
273
 
234
- def authenticate(self):
235
- self.authenticator.authenticate()
274
+ def authenticate(self, paste: bool = False):
275
+ self.authenticator.authenticate(paste=paste)
236
276
 
237
277
  def update(self):
238
278
  perform_update()
@@ -243,6 +283,8 @@ class GodfatherCLI:
243
283
  self.connect_to_pod()
244
284
  elif action == picker.LIST:
245
285
  self.list_pods()
286
+ elif action == picker.SESSIONS:
287
+ self.list_sessions()
246
288
  elif action == picker.STATUS:
247
289
  self.status()
248
290
  elif action == picker.LOGIN:
@@ -272,7 +314,14 @@ class GodfatherCLI:
272
314
 
273
315
  def plain_menu(self):
274
316
  """The numbered menu for input that is not a terminal."""
275
- actions = {'1': picker.LIST, '2': picker.CONNECT, '3': picker.STATUS, '4': picker.LOGOUT, '5': picker.EXIT}
317
+ actions = {
318
+ '1': picker.LIST,
319
+ '2': picker.CONNECT,
320
+ '3': picker.STATUS,
321
+ '4': picker.LOGOUT,
322
+ '5': picker.SESSIONS,
323
+ '6': picker.EXIT,
324
+ }
276
325
  while True:
277
326
  console.print()
278
327
  menu = Table.grid(padding=(0, 2))
@@ -282,7 +331,8 @@ class GodfatherCLI:
282
331
  menu.add_row("2.", "Connect to a pod")
283
332
  menu.add_row("3.", "Show status")
284
333
  menu.add_row("4.", "Log out")
285
- menu.add_row("5.", "Exit")
334
+ menu.add_row("5.", "Show sessions")
335
+ menu.add_row("6.", "Exit")
286
336
  console.print(Panel(menu, title="What would you like to do?", border_style=BORDER, box=BOX))
287
337
 
288
338
  try:
@@ -318,36 +368,35 @@ Examples:
318
368
  godfather list List available pods
319
369
  godfather connect Connect to a pod, picking from a list
320
370
  godfather connect <pod-id> Connect to a specific pod
371
+ godfather connect --admin Connect as root (officers, on a pod with pod admin on)
372
+ godfather sessions Show upcoming sessions on your pods
321
373
  godfather status Show login and configuration status
322
- godfather auth Log in or refresh your session
374
+ godfather auth Log in with Discord in your browser
375
+ godfather auth --paste Log in by pasting the token (browser on another computer)
323
376
  godfather logout Clear the stored session
324
377
  godfather update Update the CLI to the latest version
325
378
 
326
379
  Questions or issues: https://discord.gg/fXWXwz6fEG
327
- """
380
+ """,
328
381
  )
329
382
 
330
383
  parser.add_argument(
331
384
  'command',
332
385
  nargs='?',
333
- choices=['list', 'connect', 'status', 'logout', 'auth', 'update'],
334
- help='Command to run (omit to open the interactive menu)'
335
- )
336
- parser.add_argument(
337
- 'pod_id',
338
- nargs='?',
339
- help='Pod ID to connect to (only used with "connect")'
340
- )
341
- parser.add_argument(
342
- '--api-url',
343
- help='Use a specific platform API URL instead of the default'
344
- )
345
- parser.add_argument(
346
- '--org',
347
- help='Organization prefix on the platform (default: ais)'
386
+ choices=['list', 'connect', 'sessions', 'status', 'logout', 'auth', 'update'],
387
+ help='Command to run (omit to open the interactive menu)',
348
388
  )
389
+ parser.add_argument('pod_id', nargs='?', help='Pod ID to connect to (only used with "connect")')
390
+ parser.add_argument('--api-url', help='Use a specific platform API URL instead of the default')
391
+ parser.add_argument('--org', help='Organization prefix on the platform (default: ais)')
392
+ parser.add_argument('--paste', action='store_true', help='With "auth": paste the token, no browser sign-in')
393
+ parser.add_argument('--admin', action='store_true', help='With "connect": ask for a root shell')
349
394
 
350
395
  args = parser.parse_args()
396
+ if args.paste and args.command != 'auth':
397
+ parser.error('--paste works only with "auth"')
398
+ if args.admin and args.command != 'connect':
399
+ parser.error('--admin works only with "connect"')
351
400
 
352
401
  if args.api_url:
353
402
  os.environ['GODFATHER_API_URL'] = args.api_url
@@ -370,13 +419,15 @@ Questions or issues: https://discord.gg/fXWXwz6fEG
370
419
  if args.command == 'list':
371
420
  cli.list_pods()
372
421
  elif args.command == 'connect':
373
- cli.connect_to_pod(args.pod_id)
422
+ cli.connect_to_pod(args.pod_id, admin=args.admin)
423
+ elif args.command == 'sessions':
424
+ cli.list_sessions()
374
425
  elif args.command == 'status':
375
426
  cli.status()
376
427
  elif args.command == 'logout':
377
428
  cli.logout()
378
429
  elif args.command == 'auth':
379
- cli.authenticate()
430
+ cli.authenticate(paste=args.paste)
380
431
  elif args.command == 'update':
381
432
  cli.update()
382
433
 
@@ -0,0 +1,72 @@
1
+ """One-request HTTP server on 127.0.0.1 that receives the sign-in code from the browser."""
2
+
3
+ import hmac
4
+ import time
5
+ from http.server import BaseHTTPRequestHandler, HTTPServer
6
+ from typing import Optional
7
+ from urllib.parse import parse_qs, urlparse
8
+
9
+ DONE_PAGE = (
10
+ b'<!doctype html><meta charset="utf-8"><title>Godfather</title>'
11
+ b'<p>Signed in. You can close this tab and go back to the terminal.</p>'
12
+ )
13
+
14
+
15
+ class _Handler(BaseHTTPRequestHandler):
16
+ server: '_Server'
17
+
18
+ def do_GET(self) -> None:
19
+ url = urlparse(self.path)
20
+ if url.path != '/callback':
21
+ self._answer(404, b'Not found')
22
+ return
23
+ query = parse_qs(url.query)
24
+ state = (query.get('state') or [''])[0]
25
+ code = (query.get('code') or [''])[0]
26
+ if not code or not hmac.compare_digest(state.encode(), self.server.expected_state.encode()):
27
+ self._answer(400, b'This sign-in link is not for this terminal.')
28
+ return
29
+ self.server.code = code
30
+ self._answer(200, DONE_PAGE, 'text/html; charset=utf-8')
31
+
32
+ def _answer(self, status: int, body: bytes, content_type: str = 'text/plain; charset=utf-8') -> None:
33
+ self.send_response(status)
34
+ self.send_header('Content-Type', content_type)
35
+ self.send_header('Content-Length', str(len(body)))
36
+ self.send_header('Cache-Control', 'no-store')
37
+ self.end_headers()
38
+ self.wfile.write(body)
39
+
40
+ def log_message(self, format: str, *args) -> None:
41
+ """Request lines carry the code, so nothing is logged."""
42
+
43
+
44
+ class _Server(HTTPServer):
45
+ expected_state = ''
46
+ code: Optional[str] = None
47
+
48
+
49
+ class LoopbackReceiver:
50
+ """Listens on 127.0.0.1 on a port the OS picks and keeps the code from GET /callback."""
51
+
52
+ def __init__(self, state: str):
53
+ self._server = _Server(('127.0.0.1', 0), _Handler)
54
+ self._server.expected_state = state
55
+
56
+ @property
57
+ def port(self) -> int:
58
+ return self._server.server_address[1]
59
+
60
+ def wait(self, timeout: float) -> Optional[str]:
61
+ """The code from the browser, or None when timeout seconds pass first."""
62
+ deadline = time.monotonic() + timeout
63
+ while self._server.code is None:
64
+ left = deadline - time.monotonic()
65
+ if left <= 0:
66
+ return None
67
+ self._server.timeout = min(left, 1.0)
68
+ self._server.handle_request()
69
+ return self._server.code
70
+
71
+ def close(self) -> None:
72
+ self._server.server_close()