bamboo-ssh 0.1.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 (38) hide show
  1. bamboo_ssh-0.1.0/PKG-INFO +139 -0
  2. bamboo_ssh-0.1.0/README.md +120 -0
  3. bamboo_ssh-0.1.0/bamboo_ssh/__init__.py +5 -0
  4. bamboo_ssh-0.1.0/bamboo_ssh/adapters/__init__.py +6 -0
  5. bamboo_ssh-0.1.0/bamboo_ssh/adapters/fastapi_app.py +236 -0
  6. bamboo_ssh-0.1.0/bamboo_ssh/auth/__init__.py +14 -0
  7. bamboo_ssh-0.1.0/bamboo_ssh/auth/cli.py +85 -0
  8. bamboo_ssh-0.1.0/bamboo_ssh/auth/config.py +205 -0
  9. bamboo_ssh-0.1.0/bamboo_ssh/auth/passwords.py +18 -0
  10. bamboo_ssh-0.1.0/bamboo_ssh/auth/sessions.py +114 -0
  11. bamboo_ssh-0.1.0/bamboo_ssh/cli.py +115 -0
  12. bamboo_ssh-0.1.0/bamboo_ssh/core/__init__.py +6 -0
  13. bamboo_ssh-0.1.0/bamboo_ssh/core/session.py +126 -0
  14. bamboo_ssh-0.1.0/bamboo_ssh/static/app.js +129 -0
  15. bamboo_ssh-0.1.0/bamboo_ssh/static/index.html +33 -0
  16. bamboo_ssh-0.1.0/bamboo_ssh/static/styles.css +93 -0
  17. bamboo_ssh-0.1.0/bamboo_ssh/static/vendor/xterm-addon-fit.js +2 -0
  18. bamboo_ssh-0.1.0/bamboo_ssh/static/vendor/xterm.css +218 -0
  19. bamboo_ssh-0.1.0/bamboo_ssh/static/vendor/xterm.js +2 -0
  20. bamboo_ssh-0.1.0/bamboo_ssh/ui/templates/login.html +77 -0
  21. bamboo_ssh-0.1.0/bamboo_ssh.egg-info/PKG-INFO +139 -0
  22. bamboo_ssh-0.1.0/bamboo_ssh.egg-info/SOURCES.txt +36 -0
  23. bamboo_ssh-0.1.0/bamboo_ssh.egg-info/dependency_links.txt +1 -0
  24. bamboo_ssh-0.1.0/bamboo_ssh.egg-info/entry_points.txt +2 -0
  25. bamboo_ssh-0.1.0/bamboo_ssh.egg-info/requires.txt +15 -0
  26. bamboo_ssh-0.1.0/bamboo_ssh.egg-info/top_level.txt +1 -0
  27. bamboo_ssh-0.1.0/pyproject.toml +47 -0
  28. bamboo_ssh-0.1.0/setup.cfg +4 -0
  29. bamboo_ssh-0.1.0/tests/test_app.py +156 -0
  30. bamboo_ssh-0.1.0/tests/test_auth_cli.py +248 -0
  31. bamboo_ssh-0.1.0/tests/test_auth_config.py +159 -0
  32. bamboo_ssh-0.1.0/tests/test_auth_passwords.py +19 -0
  33. bamboo_ssh-0.1.0/tests/test_auth_sessions.py +215 -0
  34. bamboo_ssh-0.1.0/tests/test_docker_support.py +28 -0
  35. bamboo_ssh-0.1.0/tests/test_makefile.py +66 -0
  36. bamboo_ssh-0.1.0/tests/test_packaging.py +302 -0
  37. bamboo_ssh-0.1.0/tests/test_web_auth.py +123 -0
  38. bamboo_ssh-0.1.0/tests/test_websocket.py +98 -0
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: bamboo-ssh
3
+ Version: 0.1.0
4
+ Summary: Reusable terminal session core with optional web terminal extras.
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Provides-Extra: web
8
+ Requires-Dist: fastapi<1,>=0.136; extra == "web"
9
+ Requires-Dist: uvicorn<1,>=0.44; extra == "web"
10
+ Requires-Dist: websockets<17,>=13; extra == "web"
11
+ Provides-Extra: full
12
+ Requires-Dist: fastapi<1,>=0.136; extra == "full"
13
+ Requires-Dist: uvicorn<1,>=0.44; extra == "full"
14
+ Requires-Dist: websockets<17,>=13; extra == "full"
15
+ Requires-Dist: argon2-cffi<24.0,>=23.1; extra == "full"
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest==9.0.3; extra == "dev"
18
+ Requires-Dist: httpx==0.28.1; extra == "dev"
19
+
20
+ # Bamboo SSH
21
+
22
+ This project currently provides a reusable PTY-backed shell session core and an optional FastAPI web terminal adapter. It is designed for local or otherwise trusted environments.
23
+
24
+ It is not an SSH transport layer yet. The current implementation launches a real shell on the host where the service runs.
25
+
26
+ Requires Python 3.12 or newer.
27
+
28
+ ## Full Documentation
29
+
30
+ See the documentation set in [`docs/`](./docs/README.md):
31
+
32
+ - [Project Overview](./docs/project-overview.md)
33
+ - [Architecture](./docs/architecture.md)
34
+ - [Setup and Run](./docs/setup-and-run.md)
35
+ - [Docker Support](./docs/docker.md)
36
+ - [API and WebSocket Protocol](./docs/api-reference.md)
37
+ - [File Reference](./docs/file-reference.md)
38
+ - [Testing](./docs/testing.md)
39
+ - [Security and Limitations](./docs/security-and-limitations.md)
40
+
41
+ ## Install Profiles
42
+
43
+ The package supports three install modes:
44
+
45
+ - `pip install bamboo-ssh` for the reusable core package
46
+ - `pip install "bamboo-ssh[web]"` for the FastAPI web terminal adapter
47
+ - `pip install "bamboo-ssh[full]"` for the web adapter plus built-in config-file auth helpers
48
+
49
+ From this checkout, install the matching local package profile with:
50
+
51
+ ```bash
52
+ python -m pip install .
53
+ python -m pip install ".[web]"
54
+ python -m pip install ".[full]"
55
+ ```
56
+
57
+ Use `requirements.txt` for contributor setup. It installs the editable project with the `full` and `dev` extras.
58
+
59
+ ## Make Targets
60
+
61
+ For local contributor and release helpers:
62
+
63
+ - `make install` creates `.venv` and installs `requirements.txt`
64
+ - `make clean` removes local build and test artifacts
65
+ - `make build` creates a source distribution and wheel in `dist/`
66
+ - `make deploy` builds and uploads `dist/*` to PyPI with `twine`
67
+
68
+ For `make deploy`, authenticate with PyPI first, typically with a token in `TWINE_PASSWORD`.
69
+
70
+ ## Quick Start
71
+
72
+ ```bash
73
+ python3 -m venv .venv
74
+ . .venv/bin/activate
75
+ python -m pip install ".[full]"
76
+ bamboo-ssh auth init
77
+ bamboo-ssh serve --host 127.0.0.1 --port 8765
78
+ ```
79
+
80
+ Open `http://127.0.0.1:8765/`.
81
+
82
+ If the default config file exists, `bamboo-ssh serve` loads it automatically. Use `--config /path/to/config.toml` to point at a different file.
83
+
84
+ ## Embedded Usage
85
+
86
+ Other Python projects can import the package without using the built-in web auth flow.
87
+
88
+ Use the core session API directly:
89
+
90
+ ```python
91
+ from bamboo_ssh.core.session import TerminalSession
92
+
93
+ session = TerminalSession.start(cols=80, rows=24)
94
+ session.write("printf 'hello\\n'\n")
95
+ session.close()
96
+ ```
97
+
98
+ Or mount the web adapter inside another backend:
99
+
100
+ ```python
101
+ from bamboo_ssh.adapters.fastapi_app import create_app
102
+
103
+ terminal_app = create_app()
104
+ protected_terminal_app = create_app(config_path="/etc/bamboo-ssh/config.toml")
105
+ ```
106
+
107
+ ## Built-In Auth
108
+
109
+ The standalone auth mode is intentionally simple:
110
+
111
+ - one admin username
112
+ - password hash stored in a TOML config file
113
+ - signed session cookie for the browser
114
+ - no database
115
+
116
+ Manage it with:
117
+
118
+ ```bash
119
+ bamboo-ssh auth init
120
+ bamboo-ssh auth set-password
121
+ bamboo-ssh auth show-config-path
122
+ ```
123
+
124
+ After login, the terminal page loads normally. `POST /logout` clears the session cookie and returns the browser to the login page.
125
+
126
+ ## Shell Startup Tuning
127
+
128
+ Web terminal shell sessions export `WEB_TERMINAL=1`.
129
+
130
+ If your shell startup files do expensive optional work such as loading large completion bundles, language managers, or interactive prompts, you can use that variable to skip them for browser terminal sessions while keeping a full login shell for normal terminal use.
131
+
132
+ ## Docker Quick Start
133
+
134
+ ```bash
135
+ cp docker/.env.example docker/.env
136
+ docker compose -f docker/compose.yml --env-file docker/.env up --build
137
+ ```
138
+
139
+ This runs the terminal inside a Python 3.12 container. The browser terminal connects to the shell inside the container, not the host machine shell.
@@ -0,0 +1,120 @@
1
+ # Bamboo SSH
2
+
3
+ This project currently provides a reusable PTY-backed shell session core and an optional FastAPI web terminal adapter. It is designed for local or otherwise trusted environments.
4
+
5
+ It is not an SSH transport layer yet. The current implementation launches a real shell on the host where the service runs.
6
+
7
+ Requires Python 3.12 or newer.
8
+
9
+ ## Full Documentation
10
+
11
+ See the documentation set in [`docs/`](./docs/README.md):
12
+
13
+ - [Project Overview](./docs/project-overview.md)
14
+ - [Architecture](./docs/architecture.md)
15
+ - [Setup and Run](./docs/setup-and-run.md)
16
+ - [Docker Support](./docs/docker.md)
17
+ - [API and WebSocket Protocol](./docs/api-reference.md)
18
+ - [File Reference](./docs/file-reference.md)
19
+ - [Testing](./docs/testing.md)
20
+ - [Security and Limitations](./docs/security-and-limitations.md)
21
+
22
+ ## Install Profiles
23
+
24
+ The package supports three install modes:
25
+
26
+ - `pip install bamboo-ssh` for the reusable core package
27
+ - `pip install "bamboo-ssh[web]"` for the FastAPI web terminal adapter
28
+ - `pip install "bamboo-ssh[full]"` for the web adapter plus built-in config-file auth helpers
29
+
30
+ From this checkout, install the matching local package profile with:
31
+
32
+ ```bash
33
+ python -m pip install .
34
+ python -m pip install ".[web]"
35
+ python -m pip install ".[full]"
36
+ ```
37
+
38
+ Use `requirements.txt` for contributor setup. It installs the editable project with the `full` and `dev` extras.
39
+
40
+ ## Make Targets
41
+
42
+ For local contributor and release helpers:
43
+
44
+ - `make install` creates `.venv` and installs `requirements.txt`
45
+ - `make clean` removes local build and test artifacts
46
+ - `make build` creates a source distribution and wheel in `dist/`
47
+ - `make deploy` builds and uploads `dist/*` to PyPI with `twine`
48
+
49
+ For `make deploy`, authenticate with PyPI first, typically with a token in `TWINE_PASSWORD`.
50
+
51
+ ## Quick Start
52
+
53
+ ```bash
54
+ python3 -m venv .venv
55
+ . .venv/bin/activate
56
+ python -m pip install ".[full]"
57
+ bamboo-ssh auth init
58
+ bamboo-ssh serve --host 127.0.0.1 --port 8765
59
+ ```
60
+
61
+ Open `http://127.0.0.1:8765/`.
62
+
63
+ If the default config file exists, `bamboo-ssh serve` loads it automatically. Use `--config /path/to/config.toml` to point at a different file.
64
+
65
+ ## Embedded Usage
66
+
67
+ Other Python projects can import the package without using the built-in web auth flow.
68
+
69
+ Use the core session API directly:
70
+
71
+ ```python
72
+ from bamboo_ssh.core.session import TerminalSession
73
+
74
+ session = TerminalSession.start(cols=80, rows=24)
75
+ session.write("printf 'hello\\n'\n")
76
+ session.close()
77
+ ```
78
+
79
+ Or mount the web adapter inside another backend:
80
+
81
+ ```python
82
+ from bamboo_ssh.adapters.fastapi_app import create_app
83
+
84
+ terminal_app = create_app()
85
+ protected_terminal_app = create_app(config_path="/etc/bamboo-ssh/config.toml")
86
+ ```
87
+
88
+ ## Built-In Auth
89
+
90
+ The standalone auth mode is intentionally simple:
91
+
92
+ - one admin username
93
+ - password hash stored in a TOML config file
94
+ - signed session cookie for the browser
95
+ - no database
96
+
97
+ Manage it with:
98
+
99
+ ```bash
100
+ bamboo-ssh auth init
101
+ bamboo-ssh auth set-password
102
+ bamboo-ssh auth show-config-path
103
+ ```
104
+
105
+ After login, the terminal page loads normally. `POST /logout` clears the session cookie and returns the browser to the login page.
106
+
107
+ ## Shell Startup Tuning
108
+
109
+ Web terminal shell sessions export `WEB_TERMINAL=1`.
110
+
111
+ If your shell startup files do expensive optional work such as loading large completion bundles, language managers, or interactive prompts, you can use that variable to skip them for browser terminal sessions while keeping a full login shell for normal terminal use.
112
+
113
+ ## Docker Quick Start
114
+
115
+ ```bash
116
+ cp docker/.env.example docker/.env
117
+ docker compose -f docker/compose.yml --env-file docker/.env up --build
118
+ ```
119
+
120
+ This runs the terminal inside a Python 3.12 container. The browser terminal connects to the shell inside the container, not the host machine shell.
@@ -0,0 +1,5 @@
1
+ """Bamboo SSH package."""
2
+
3
+ from .core import TerminalSession, stream_terminal_output
4
+
5
+ __all__ = ["TerminalSession", "stream_terminal_output"]
@@ -0,0 +1,6 @@
1
+ """FastAPI adapter exports."""
2
+
3
+ from .fastapi_app import app, build_arg_parser, main
4
+
5
+ __all__ = ["app", "build_arg_parser", "main"]
6
+
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import contextlib
6
+ from html import escape
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import parse_qs
10
+
11
+ import uvicorn
12
+ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, status
13
+ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
14
+ from fastapi.staticfiles import StaticFiles
15
+
16
+ from bamboo_ssh.core.session import TerminalSession, stream_terminal_output
17
+
18
+
19
+ SESSION_COOKIE_NAME = "bamboo_ssh_session"
20
+
21
+
22
+ def _resolve_static_dir(
23
+ package_static_dir: Path | None = None,
24
+ checkout_static_dir: Path | None = None,
25
+ ) -> Path:
26
+ package_root = Path(__file__).resolve().parents[1]
27
+ candidates = (
28
+ package_static_dir or package_root / "static",
29
+ checkout_static_dir or package_root.parent / "static",
30
+ )
31
+ for candidate in candidates:
32
+ if candidate.is_dir():
33
+ return candidate
34
+ raise FileNotFoundError(f"Could not find static assets in: {', '.join(str(path) for path in candidates)}")
35
+
36
+
37
+ def _resolve_login_template(package_template_path: Path | None = None) -> Path:
38
+ package_root = Path(__file__).resolve().parents[1]
39
+ candidate = package_template_path or package_root / "ui" / "templates" / "login.html"
40
+ if candidate.is_file():
41
+ return candidate
42
+ raise FileNotFoundError(f"Could not find login template: {candidate}")
43
+
44
+
45
+ def _load_auth_settings(config: Any | None, config_path: str | Path | None) -> Any | None:
46
+ if config is not None:
47
+ auth_settings = getattr(config, "auth", None)
48
+ return auth_settings if auth_settings is not None and getattr(auth_settings, "enabled", False) else None
49
+
50
+ if config_path is None:
51
+ return None
52
+
53
+ resolved_config_path = Path(config_path).expanduser().resolve()
54
+ if not resolved_config_path.exists():
55
+ raise FileNotFoundError(f"Config file not found: {resolved_config_path}")
56
+
57
+ from bamboo_ssh.auth.config import load_config
58
+
59
+ auth_settings = load_config(resolved_config_path).auth
60
+ return auth_settings if auth_settings is not None and auth_settings.enabled else None
61
+
62
+
63
+ def _read_login_template(template_path: Path, error_message: str | None = None) -> str:
64
+ template = template_path.read_text(encoding="utf-8")
65
+ error_html = ""
66
+ if error_message:
67
+ error_html = f'<p class="login-error" role="alert">{escape(error_message)}</p>'
68
+ return template.replace("{{ERROR_BLOCK}}", error_html)
69
+
70
+
71
+ def _parse_form_body(body: bytes) -> dict[str, str]:
72
+ try:
73
+ parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
74
+ except UnicodeDecodeError:
75
+ return {}
76
+
77
+ return {key: values[-1] if values else "" for key, values in parsed.items()}
78
+
79
+
80
+ def _validate_session_token(token: str | None, auth_settings: Any | None) -> bool:
81
+ if auth_settings is None or not token:
82
+ return False
83
+
84
+ from bamboo_ssh.auth.sessions import validate_session_token
85
+
86
+ return validate_session_token(token, auth_settings) is not None
87
+
88
+
89
+ def _is_authenticated(cookies: dict[str, str], auth_settings: Any | None) -> bool:
90
+ if auth_settings is None:
91
+ return True
92
+ return _validate_session_token(cookies.get(SESSION_COOKIE_NAME), auth_settings)
93
+
94
+
95
+ def _redirect(location: str) -> RedirectResponse:
96
+ return RedirectResponse(location, status_code=status.HTTP_303_SEE_OTHER)
97
+
98
+
99
+ def _render_login_page(template_path: Path, error_message: str | None = None, status_code: int = 200) -> HTMLResponse:
100
+ return HTMLResponse(_read_login_template(template_path, error_message), status_code=status_code)
101
+
102
+
103
+ def _parse_positive_int(value: str | None, default: int) -> int:
104
+ try:
105
+ parsed = int(value or "")
106
+ except ValueError:
107
+ return default
108
+ return parsed if parsed > 0 else default
109
+
110
+
111
+ def create_app(
112
+ *,
113
+ config: Any | None = None,
114
+ config_path: str | Path | None = None,
115
+ package_static_dir: Path | None = None,
116
+ checkout_static_dir: Path | None = None,
117
+ login_template_path: Path | None = None,
118
+ ) -> FastAPI:
119
+ static_dir = _resolve_static_dir(package_static_dir, checkout_static_dir)
120
+ template_path = _resolve_login_template(login_template_path)
121
+ auth_settings = _load_auth_settings(config, config_path)
122
+
123
+ app = FastAPI(title="Python Web Terminal")
124
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
125
+
126
+ @app.get("/")
127
+ async def index(request: Request) -> Response:
128
+ if not _is_authenticated(request.cookies, auth_settings):
129
+ return _redirect("/login")
130
+ return FileResponse(static_dir / "index.html")
131
+
132
+ @app.get("/login")
133
+ async def login_page(request: Request) -> Response:
134
+ if auth_settings is None or _is_authenticated(request.cookies, auth_settings):
135
+ return _redirect("/")
136
+ return _render_login_page(template_path)
137
+
138
+ @app.post("/login")
139
+ async def login(request: Request) -> Response:
140
+ if auth_settings is None:
141
+ return _redirect("/")
142
+
143
+ form_data = _parse_form_body(await request.body())
144
+ username = form_data.get("username", "")
145
+ password = form_data.get("password", "")
146
+
147
+ from bamboo_ssh.auth.passwords import verify_password
148
+ from bamboo_ssh.auth.sessions import issue_session_token
149
+
150
+ if username != auth_settings.username or not verify_password(password, auth_settings.password_hash):
151
+ return _render_login_page(template_path, "Invalid username or password.", status.HTTP_401_UNAUTHORIZED)
152
+
153
+ response = _redirect("/")
154
+ response.set_cookie(
155
+ SESSION_COOKIE_NAME,
156
+ issue_session_token(auth_settings),
157
+ httponly=True,
158
+ max_age=auth_settings.session_ttl_seconds,
159
+ path="/",
160
+ samesite="lax",
161
+ secure=request.url.scheme == "https",
162
+ )
163
+ return response
164
+
165
+ @app.post("/logout")
166
+ async def logout() -> RedirectResponse:
167
+ response = _redirect("/login" if auth_settings is not None else "/")
168
+ response.delete_cookie(SESSION_COOKIE_NAME, path="/")
169
+ return response
170
+
171
+ @app.websocket("/ws/terminal")
172
+ async def terminal_socket(websocket: WebSocket) -> None:
173
+ if not _is_authenticated(websocket.cookies, auth_settings):
174
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
175
+ return
176
+
177
+ await websocket.accept()
178
+
179
+ cols = _parse_positive_int(websocket.query_params.get("cols"), 80)
180
+ rows = _parse_positive_int(websocket.query_params.get("rows"), 24)
181
+ command = websocket.query_params.get("command", "")
182
+
183
+ session = TerminalSession.start(cols=cols, rows=rows, command=command)
184
+ send_lock = asyncio.Lock()
185
+
186
+ async def send_json(payload: dict) -> None:
187
+ async with send_lock:
188
+ await websocket.send_json(payload)
189
+
190
+ reader_task = asyncio.create_task(stream_terminal_output(session, send_json))
191
+
192
+ try:
193
+ while True:
194
+ payload = await websocket.receive_json()
195
+ msg_type = payload.get("type")
196
+
197
+ if msg_type == "cmd":
198
+ data = payload.get("data", "")
199
+ if isinstance(data, str) and data:
200
+ session.write(data)
201
+ elif msg_type == "resize":
202
+ next_cols = _parse_positive_int(str(payload.get("cols", "")), cols)
203
+ next_rows = _parse_positive_int(str(payload.get("rows", "")), rows)
204
+ session.resize(next_cols, next_rows)
205
+ cols, rows = next_cols, next_rows
206
+ elif msg_type == "heartbeat":
207
+ await send_json({"type": "heartbeat", "timestamp": payload.get("timestamp")})
208
+ else:
209
+ await send_json({"type": "error", "data": f"unsupported message type: {msg_type}"})
210
+ except WebSocketDisconnect:
211
+ pass
212
+ finally:
213
+ session.close()
214
+ reader_task.cancel()
215
+ with contextlib.suppress(asyncio.CancelledError):
216
+ await reader_task
217
+
218
+ return app
219
+
220
+
221
+ STATIC_DIR = _resolve_static_dir()
222
+ LOGIN_TEMPLATE = _resolve_login_template()
223
+ app = create_app()
224
+
225
+
226
+ def build_arg_parser() -> argparse.ArgumentParser:
227
+ parser = argparse.ArgumentParser(description="Run the Python web terminal server.")
228
+ parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind")
229
+ parser.add_argument("--port", type=int, default=8765, help="Port to listen on")
230
+ parser.add_argument("--config", help="Path to the Bamboo SSH config file.")
231
+ return parser
232
+
233
+
234
+ def main() -> None:
235
+ args = build_arg_parser().parse_args()
236
+ uvicorn.run(create_app(config_path=args.config), host=args.host, port=args.port)
@@ -0,0 +1,14 @@
1
+ """Standalone authentication helpers for Bamboo SSH."""
2
+
3
+ from .config import AuthSettings, BambooSSHConfig, ServerSettings, load_config, save_config
4
+ from .passwords import hash_password, verify_password
5
+
6
+ __all__ = [
7
+ "AuthSettings",
8
+ "BambooSSHConfig",
9
+ "ServerSettings",
10
+ "hash_password",
11
+ "load_config",
12
+ "save_config",
13
+ "verify_password",
14
+ ]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from dataclasses import replace
5
+ import getpass
6
+ import os
7
+ from pathlib import Path
8
+ import secrets
9
+
10
+ from .config import AuthSettings, BambooSSHConfig, load_config, save_config
11
+
12
+
13
+ def resolve_config_path(config_path: str | None) -> Path:
14
+ if config_path:
15
+ return Path(config_path).expanduser().resolve()
16
+
17
+ config_home = os.environ.get("XDG_CONFIG_HOME")
18
+ if config_home:
19
+ base_directory = Path(config_home)
20
+ else:
21
+ base_directory = Path.home() / ".config"
22
+
23
+ return (base_directory / "bamboo-ssh" / "config.toml").expanduser().resolve()
24
+
25
+
26
+ def run_init(args: argparse.Namespace) -> int:
27
+ from .passwords import hash_password
28
+
29
+ config_path = resolve_config_path(args.config)
30
+ password = _prompt_password()
31
+ existing_config = load_config(config_path) if config_path.exists() else BambooSSHConfig()
32
+ existing_auth = existing_config.auth
33
+
34
+ auth_settings = AuthSettings(
35
+ enabled=True if existing_auth is None else existing_auth.enabled,
36
+ username=args.username,
37
+ password_hash=hash_password(password),
38
+ session_secret=secrets.token_urlsafe(32),
39
+ session_ttl_seconds=43200 if existing_auth is None else existing_auth.session_ttl_seconds,
40
+ )
41
+ save_config(config_path, BambooSSHConfig(server=existing_config.server, auth=auth_settings))
42
+ print(config_path)
43
+ return 0
44
+
45
+
46
+ def run_set_password(args: argparse.Namespace) -> int:
47
+ from .passwords import hash_password
48
+
49
+ config_path = resolve_config_path(args.config)
50
+ if not config_path.exists():
51
+ raise SystemExit(f"Config file not found: {config_path}")
52
+
53
+ config = load_config(config_path)
54
+ if config.auth is None:
55
+ raise SystemExit(f"Config file does not contain an [auth] section: {config_path}")
56
+ if args.username is not None and args.username != config.auth.username:
57
+ raise SystemExit(
58
+ f"Configured username is {config.auth.username!r}, not {args.username!r}."
59
+ )
60
+
61
+ password = _prompt_password()
62
+ updated_auth = replace(config.auth, password_hash=hash_password(password))
63
+ save_config(config_path, BambooSSHConfig(server=config.server, auth=updated_auth))
64
+ print(config_path)
65
+ return 0
66
+
67
+
68
+ def run_show_config_path(args: argparse.Namespace) -> int:
69
+ print(resolve_config_path(args.config))
70
+ return 0
71
+
72
+
73
+ def _prompt_password() -> str:
74
+ try:
75
+ password = getpass.getpass("Password: ")
76
+ confirmation = getpass.getpass("Confirm password: ")
77
+ except (EOFError, KeyboardInterrupt) as exc:
78
+ raise SystemExit("Password prompt aborted.") from exc
79
+
80
+ if not password:
81
+ raise SystemExit("Password must not be empty.")
82
+ if password != confirmation:
83
+ raise SystemExit("Passwords did not match.")
84
+
85
+ return password