bamboo-ssh 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.
- bamboo_ssh/__init__.py +5 -0
- bamboo_ssh/adapters/__init__.py +6 -0
- bamboo_ssh/adapters/fastapi_app.py +236 -0
- bamboo_ssh/auth/__init__.py +14 -0
- bamboo_ssh/auth/cli.py +85 -0
- bamboo_ssh/auth/config.py +205 -0
- bamboo_ssh/auth/passwords.py +18 -0
- bamboo_ssh/auth/sessions.py +114 -0
- bamboo_ssh/cli.py +115 -0
- bamboo_ssh/core/__init__.py +6 -0
- bamboo_ssh/core/session.py +126 -0
- bamboo_ssh/static/app.js +129 -0
- bamboo_ssh/static/index.html +33 -0
- bamboo_ssh/static/styles.css +93 -0
- bamboo_ssh/static/vendor/xterm-addon-fit.js +2 -0
- bamboo_ssh/static/vendor/xterm.css +218 -0
- bamboo_ssh/static/vendor/xterm.js +2 -0
- bamboo_ssh/ui/templates/login.html +77 -0
- bamboo_ssh-0.1.0.dist-info/METADATA +139 -0
- bamboo_ssh-0.1.0.dist-info/RECORD +23 -0
- bamboo_ssh-0.1.0.dist-info/WHEEL +5 -0
- bamboo_ssh-0.1.0.dist-info/entry_points.txt +2 -0
- bamboo_ssh-0.1.0.dist-info/top_level.txt +1 -0
bamboo_ssh/__init__.py
ADDED
|
@@ -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
|
+
]
|
bamboo_ssh/auth/cli.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tempfile
|
|
8
|
+
import tomllib
|
|
9
|
+
from typing import Any, Mapping
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class ServerSettings:
|
|
14
|
+
host: str = "127.0.0.1"
|
|
15
|
+
port: int = 8765
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
_require_string("server.host", self.host)
|
|
19
|
+
_require_tcp_port("server.port", self.port)
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_mapping(cls, data: Mapping[str, Any] | None) -> "ServerSettings":
|
|
23
|
+
section = data or {}
|
|
24
|
+
host = section.get("host", "127.0.0.1")
|
|
25
|
+
port = section.get("port", 8765)
|
|
26
|
+
|
|
27
|
+
return cls(host=host, port=port)
|
|
28
|
+
|
|
29
|
+
def to_mapping(self) -> dict[str, Any]:
|
|
30
|
+
return {"host": self.host, "port": self.port}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class AuthSettings:
|
|
35
|
+
username: str
|
|
36
|
+
password_hash: str
|
|
37
|
+
session_secret: str
|
|
38
|
+
enabled: bool = True
|
|
39
|
+
session_ttl_seconds: int = 43200
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
_require_bool("auth.enabled", self.enabled)
|
|
43
|
+
_require_non_empty_string("auth.username", self.username)
|
|
44
|
+
_require_non_empty_string("auth.password_hash", self.password_hash)
|
|
45
|
+
_require_non_empty_string("auth.session_secret", self.session_secret)
|
|
46
|
+
_require_positive_integer("auth.session_ttl_seconds", self.session_ttl_seconds)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_mapping(cls, data: Mapping[str, Any]) -> "AuthSettings":
|
|
50
|
+
enabled = data.get("enabled", True)
|
|
51
|
+
username = data.get("username")
|
|
52
|
+
password_hash = data.get("password_hash")
|
|
53
|
+
session_secret = data.get("session_secret")
|
|
54
|
+
session_ttl_seconds = data.get("session_ttl_seconds", 43200)
|
|
55
|
+
|
|
56
|
+
return cls(
|
|
57
|
+
enabled=enabled,
|
|
58
|
+
username=username,
|
|
59
|
+
password_hash=password_hash,
|
|
60
|
+
session_secret=session_secret,
|
|
61
|
+
session_ttl_seconds=session_ttl_seconds,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def to_mapping(self) -> dict[str, Any]:
|
|
65
|
+
return {
|
|
66
|
+
"enabled": self.enabled,
|
|
67
|
+
"username": self.username,
|
|
68
|
+
"password_hash": self.password_hash,
|
|
69
|
+
"session_secret": self.session_secret,
|
|
70
|
+
"session_ttl_seconds": self.session_ttl_seconds,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class BambooSSHConfig:
|
|
76
|
+
server: ServerSettings = field(default_factory=ServerSettings)
|
|
77
|
+
auth: AuthSettings | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_config(path: str | Path) -> BambooSSHConfig:
|
|
81
|
+
config_path = Path(path)
|
|
82
|
+
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
83
|
+
|
|
84
|
+
server_data = data.get("server")
|
|
85
|
+
if server_data is not None and not isinstance(server_data, dict):
|
|
86
|
+
raise ValueError("server section must be a table")
|
|
87
|
+
|
|
88
|
+
auth_data = data.get("auth")
|
|
89
|
+
if auth_data is not None and not isinstance(auth_data, dict):
|
|
90
|
+
raise ValueError("auth section must be a table")
|
|
91
|
+
|
|
92
|
+
return BambooSSHConfig(
|
|
93
|
+
server=ServerSettings.from_mapping(server_data),
|
|
94
|
+
auth=AuthSettings.from_mapping(auth_data) if auth_data is not None else None,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def save_config(path: str | Path, config: BambooSSHConfig) -> None:
|
|
99
|
+
config_path = Path(path)
|
|
100
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
serialized = _serialize_config(config)
|
|
102
|
+
|
|
103
|
+
tmp_file = tempfile.NamedTemporaryFile(
|
|
104
|
+
mode="w",
|
|
105
|
+
encoding="utf-8",
|
|
106
|
+
dir=config_path.parent,
|
|
107
|
+
prefix=f".{config_path.name}.",
|
|
108
|
+
suffix=".tmp",
|
|
109
|
+
delete=False,
|
|
110
|
+
)
|
|
111
|
+
tmp_path = Path(tmp_file.name)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
if os.name != "nt":
|
|
115
|
+
os.chmod(tmp_path, 0o600)
|
|
116
|
+
|
|
117
|
+
with tmp_file:
|
|
118
|
+
tmp_file.write(serialized)
|
|
119
|
+
tmp_file.flush()
|
|
120
|
+
os.fsync(tmp_file.fileno())
|
|
121
|
+
|
|
122
|
+
os.replace(tmp_path, config_path)
|
|
123
|
+
_fsync_parent_directory(config_path.parent)
|
|
124
|
+
except Exception:
|
|
125
|
+
if tmp_path.exists():
|
|
126
|
+
tmp_path.unlink()
|
|
127
|
+
raise
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _serialize_config(config: BambooSSHConfig) -> str:
|
|
131
|
+
sections: list[tuple[str, dict[str, Any]]] = [("server", config.server.to_mapping())]
|
|
132
|
+
if config.auth is not None:
|
|
133
|
+
sections.append(("auth", config.auth.to_mapping()))
|
|
134
|
+
|
|
135
|
+
lines: list[str] = []
|
|
136
|
+
for index, (name, values) in enumerate(sections):
|
|
137
|
+
if index:
|
|
138
|
+
lines.append("")
|
|
139
|
+
lines.append(f"[{name}]")
|
|
140
|
+
for key, value in values.items():
|
|
141
|
+
lines.append(f"{key} = {_format_toml_value(value)}")
|
|
142
|
+
|
|
143
|
+
lines.append("")
|
|
144
|
+
return "\n".join(lines)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _format_toml_value(value: Any) -> str:
|
|
148
|
+
if isinstance(value, bool):
|
|
149
|
+
return "true" if value else "false"
|
|
150
|
+
if isinstance(value, int):
|
|
151
|
+
return str(value)
|
|
152
|
+
if isinstance(value, str):
|
|
153
|
+
return json.dumps(value)
|
|
154
|
+
raise TypeError(f"unsupported TOML value: {value!r}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _require_bool(field_name: str, value: Any) -> bool:
|
|
158
|
+
if not isinstance(value, bool):
|
|
159
|
+
raise ValueError(f"{field_name} must be a boolean")
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _require_string(field_name: str, value: Any) -> str:
|
|
164
|
+
if not isinstance(value, str):
|
|
165
|
+
raise ValueError(f"{field_name} must be a string")
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _require_non_empty_string(field_name: str, value: Any) -> str:
|
|
170
|
+
text = _require_string(field_name, value)
|
|
171
|
+
if not text:
|
|
172
|
+
raise ValueError(f"{field_name} must not be empty")
|
|
173
|
+
return text
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _require_integer(field_name: str, value: Any) -> int:
|
|
177
|
+
if type(value) is not int:
|
|
178
|
+
raise ValueError(f"{field_name} must be an integer")
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _require_positive_integer(field_name: str, value: Any) -> int:
|
|
183
|
+
integer = _require_integer(field_name, value)
|
|
184
|
+
if integer <= 0:
|
|
185
|
+
raise ValueError(f"{field_name} must be positive")
|
|
186
|
+
return integer
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _require_tcp_port(field_name: str, value: Any) -> int:
|
|
190
|
+
port = _require_integer(field_name, value)
|
|
191
|
+
if not 1 <= port <= 65535:
|
|
192
|
+
raise ValueError(f"{field_name} must be between 1 and 65535")
|
|
193
|
+
return port
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _fsync_parent_directory(path: Path) -> None:
|
|
197
|
+
if os.name != "posix":
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
flags = getattr(os, "O_RDONLY", 0) | getattr(os, "O_DIRECTORY", 0)
|
|
201
|
+
directory_fd = os.open(path, flags)
|
|
202
|
+
try:
|
|
203
|
+
os.fsync(directory_fd)
|
|
204
|
+
finally:
|
|
205
|
+
os.close(directory_fd)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argon2 import PasswordHasher
|
|
4
|
+
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_PASSWORD_HASHER = PasswordHasher()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def hash_password(password: str) -> str:
|
|
11
|
+
return _PASSWORD_HASHER.hash(password)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verify_password(password: str, password_hash: str) -> bool:
|
|
15
|
+
try:
|
|
16
|
+
return _PASSWORD_HASHER.verify(password_hash, password)
|
|
17
|
+
except (InvalidHash, VerificationError, VerifyMismatchError):
|
|
18
|
+
return False
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from .config import AuthSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class SessionClaims:
|
|
17
|
+
username: str
|
|
18
|
+
issued_at: int
|
|
19
|
+
expires_at: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def issue_session_token(settings: AuthSettings, *, now: int | float | None = None) -> str:
|
|
23
|
+
issued_at = _current_timestamp(now)
|
|
24
|
+
expires_at = issued_at + settings.session_ttl_seconds
|
|
25
|
+
payload = {
|
|
26
|
+
"username": settings.username,
|
|
27
|
+
"iat": issued_at,
|
|
28
|
+
"exp": expires_at,
|
|
29
|
+
}
|
|
30
|
+
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
31
|
+
signature = _sign_payload(payload_bytes, settings.session_secret)
|
|
32
|
+
return f"{_urlsafe_b64encode(payload_bytes)}.{_urlsafe_b64encode(signature)}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_session_token(
|
|
36
|
+
token: str,
|
|
37
|
+
settings: AuthSettings,
|
|
38
|
+
*,
|
|
39
|
+
now: int | float | None = None,
|
|
40
|
+
) -> SessionClaims | None:
|
|
41
|
+
try:
|
|
42
|
+
payload_segment, signature_segment = token.split(".", maxsplit=1)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
payload_bytes = _urlsafe_b64decode(payload_segment)
|
|
48
|
+
signature = _urlsafe_b64decode(signature_segment)
|
|
49
|
+
except (ValueError, binascii.Error):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
expected_signature = _sign_payload(payload_bytes, settings.session_secret)
|
|
53
|
+
if not hmac.compare_digest(signature, expected_signature):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
claims = _parse_claims(payload_bytes)
|
|
57
|
+
if claims is None:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
current_time = _current_timestamp(now)
|
|
61
|
+
if claims.username != settings.username:
|
|
62
|
+
return None
|
|
63
|
+
if claims.issued_at > current_time:
|
|
64
|
+
return None
|
|
65
|
+
if claims.expires_at <= current_time:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
return claims
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_claims(payload_bytes: bytes) -> SessionClaims | None:
|
|
72
|
+
try:
|
|
73
|
+
payload = json.loads(payload_bytes)
|
|
74
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
if not isinstance(payload, dict):
|
|
78
|
+
return None
|
|
79
|
+
if set(payload) != {"username", "iat", "exp"}:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
username = payload["username"]
|
|
83
|
+
issued_at = payload["iat"]
|
|
84
|
+
expires_at = payload["exp"]
|
|
85
|
+
|
|
86
|
+
if not isinstance(username, str) or not username:
|
|
87
|
+
return None
|
|
88
|
+
if type(issued_at) is not int or type(expires_at) is not int:
|
|
89
|
+
return None
|
|
90
|
+
if expires_at <= issued_at:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
return SessionClaims(username=username, issued_at=issued_at, expires_at=expires_at)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _current_timestamp(now: int | float | None) -> int:
|
|
97
|
+
if now is None:
|
|
98
|
+
return int(time.time())
|
|
99
|
+
if isinstance(now, float) and not math.isfinite(now):
|
|
100
|
+
raise ValueError("now must be a finite timestamp")
|
|
101
|
+
return int(now)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _sign_payload(payload_bytes: bytes, session_secret: str) -> bytes:
|
|
105
|
+
return hmac.new(session_secret.encode("utf-8"), payload_bytes, hashlib.sha256).digest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _urlsafe_b64encode(data: bytes) -> str:
|
|
109
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _urlsafe_b64decode(data: str) -> bytes:
|
|
113
|
+
padding = "=" * (-len(data) % 4)
|
|
114
|
+
return base64.b64decode(data + padding, altchars=b"-_", validate=True)
|