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.
- bamboo_ssh-0.1.0/PKG-INFO +139 -0
- bamboo_ssh-0.1.0/README.md +120 -0
- bamboo_ssh-0.1.0/bamboo_ssh/__init__.py +5 -0
- bamboo_ssh-0.1.0/bamboo_ssh/adapters/__init__.py +6 -0
- bamboo_ssh-0.1.0/bamboo_ssh/adapters/fastapi_app.py +236 -0
- bamboo_ssh-0.1.0/bamboo_ssh/auth/__init__.py +14 -0
- bamboo_ssh-0.1.0/bamboo_ssh/auth/cli.py +85 -0
- bamboo_ssh-0.1.0/bamboo_ssh/auth/config.py +205 -0
- bamboo_ssh-0.1.0/bamboo_ssh/auth/passwords.py +18 -0
- bamboo_ssh-0.1.0/bamboo_ssh/auth/sessions.py +114 -0
- bamboo_ssh-0.1.0/bamboo_ssh/cli.py +115 -0
- bamboo_ssh-0.1.0/bamboo_ssh/core/__init__.py +6 -0
- bamboo_ssh-0.1.0/bamboo_ssh/core/session.py +126 -0
- bamboo_ssh-0.1.0/bamboo_ssh/static/app.js +129 -0
- bamboo_ssh-0.1.0/bamboo_ssh/static/index.html +33 -0
- bamboo_ssh-0.1.0/bamboo_ssh/static/styles.css +93 -0
- bamboo_ssh-0.1.0/bamboo_ssh/static/vendor/xterm-addon-fit.js +2 -0
- bamboo_ssh-0.1.0/bamboo_ssh/static/vendor/xterm.css +218 -0
- bamboo_ssh-0.1.0/bamboo_ssh/static/vendor/xterm.js +2 -0
- bamboo_ssh-0.1.0/bamboo_ssh/ui/templates/login.html +77 -0
- bamboo_ssh-0.1.0/bamboo_ssh.egg-info/PKG-INFO +139 -0
- bamboo_ssh-0.1.0/bamboo_ssh.egg-info/SOURCES.txt +36 -0
- bamboo_ssh-0.1.0/bamboo_ssh.egg-info/dependency_links.txt +1 -0
- bamboo_ssh-0.1.0/bamboo_ssh.egg-info/entry_points.txt +2 -0
- bamboo_ssh-0.1.0/bamboo_ssh.egg-info/requires.txt +15 -0
- bamboo_ssh-0.1.0/bamboo_ssh.egg-info/top_level.txt +1 -0
- bamboo_ssh-0.1.0/pyproject.toml +47 -0
- bamboo_ssh-0.1.0/setup.cfg +4 -0
- bamboo_ssh-0.1.0/tests/test_app.py +156 -0
- bamboo_ssh-0.1.0/tests/test_auth_cli.py +248 -0
- bamboo_ssh-0.1.0/tests/test_auth_config.py +159 -0
- bamboo_ssh-0.1.0/tests/test_auth_passwords.py +19 -0
- bamboo_ssh-0.1.0/tests/test_auth_sessions.py +215 -0
- bamboo_ssh-0.1.0/tests/test_docker_support.py +28 -0
- bamboo_ssh-0.1.0/tests/test_makefile.py +66 -0
- bamboo_ssh-0.1.0/tests/test_packaging.py +302 -0
- bamboo_ssh-0.1.0/tests/test_web_auth.py +123 -0
- 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,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
|