sshler 0.3.2__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.
- sshler/__init__.py +10 -0
- sshler/cli.py +161 -0
- sshler/config.py +425 -0
- sshler/scripts/install-sshler-task.ps1 +28 -0
- sshler/scripts/remove-sshler-task.ps1 +15 -0
- sshler/scripts/run-sshler.ps1 +24 -0
- sshler/ssh.py +329 -0
- sshler/ssh_config.py +134 -0
- sshler/state.py +230 -0
- sshler/static/base.js +305 -0
- sshler/static/favicon-terminal.svg +8 -0
- sshler/static/favicon.svg +8 -0
- sshler/static/file-edit.js +81 -0
- sshler/static/file-view.js +60 -0
- sshler/static/style.css +158 -0
- sshler/static/term.js +304 -0
- sshler/templates/base.html +41 -0
- sshler/templates/box.html +53 -0
- sshler/templates/docs.html +40 -0
- sshler/templates/file_edit.html +30 -0
- sshler/templates/file_view.html +31 -0
- sshler/templates/index.html +49 -0
- sshler/templates/new_box.html +42 -0
- sshler/templates/partials/dir_listing.html +91 -0
- sshler/templates/term.html +67 -0
- sshler/webapp.py +1897 -0
- sshler-0.3.2.dist-info/METADATA +245 -0
- sshler-0.3.2.dist-info/RECORD +31 -0
- sshler-0.3.2.dist-info/WHEEL +5 -0
- sshler-0.3.2.dist-info/entry_points.txt +2 -0
- sshler-0.3.2.dist-info/top_level.txt +1 -0
sshler/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Package metadata export for sshler."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
__all__ = ["__version__"]
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = metadata.version("sshler")
|
|
9
|
+
except metadata.PackageNotFoundError: # pragma: no cover - fallback for editable installs
|
|
10
|
+
__version__ = "0.0.0"
|
sshler/cli.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import secrets
|
|
5
|
+
import threading
|
|
6
|
+
import webbrowser
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from .webapp import ServerSettings, make_app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# open the user's browser after uvicorn starts listening
|
|
14
|
+
def _open_browser_later(application_url: str, delay: float = 0.8) -> None:
|
|
15
|
+
def open_browser() -> None:
|
|
16
|
+
try:
|
|
17
|
+
webbrowser.open(application_url)
|
|
18
|
+
except Exception:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
timer = threading.Timer(delay, open_browser)
|
|
22
|
+
timer.daemon = True
|
|
23
|
+
timer.start()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def serve(
|
|
27
|
+
host: str = "127.0.0.1",
|
|
28
|
+
port: int = 8822,
|
|
29
|
+
reload: bool = False,
|
|
30
|
+
allow_origins: list[str] | None = None,
|
|
31
|
+
basic_auth: tuple[str, str] | None = None,
|
|
32
|
+
max_upload_mb: int = 50,
|
|
33
|
+
allow_ssh_alias: bool = True,
|
|
34
|
+
log_level: str = "info",
|
|
35
|
+
open_browser: bool = True,
|
|
36
|
+
token: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Start the sshler FastAPI application via uvicorn.
|
|
39
|
+
|
|
40
|
+
English:
|
|
41
|
+
Bootstraps the FastAPI app with the provided security settings and begins
|
|
42
|
+
serving requests on the chosen host/port.
|
|
43
|
+
|
|
44
|
+
日本語:
|
|
45
|
+
指定されたセキュリティ設定で FastAPI アプリケーションを初期化し、
|
|
46
|
+
指定したホストとポートでリクエスト受付を開始します。
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
settings = ServerSettings(
|
|
50
|
+
allow_origins=allow_origins or [],
|
|
51
|
+
csrf_token=token or secrets.token_urlsafe(32),
|
|
52
|
+
max_upload_bytes=max_upload_mb * 1024 * 1024,
|
|
53
|
+
allow_ssh_alias=allow_ssh_alias,
|
|
54
|
+
basic_auth=basic_auth,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
fastapi_application = make_app(settings)
|
|
58
|
+
application_url = f"http://{host}:{port}"
|
|
59
|
+
if open_browser and host in {"127.0.0.1", "localhost"}:
|
|
60
|
+
_open_browser_later(application_url)
|
|
61
|
+
|
|
62
|
+
print(f"[sshler] listening on {application_url}")
|
|
63
|
+
print(f"[sshler] X-SSHLER-TOKEN={settings.csrf_token}")
|
|
64
|
+
if basic_auth:
|
|
65
|
+
print(f"[sshler] Basic auth enabled for user '{basic_auth[0]}'")
|
|
66
|
+
if settings.allow_origins:
|
|
67
|
+
print(f"[sshler] Additional allowed origins: {', '.join(settings.allow_origins)}")
|
|
68
|
+
|
|
69
|
+
uvicorn.run(
|
|
70
|
+
fastapi_application,
|
|
71
|
+
host=host,
|
|
72
|
+
port=port,
|
|
73
|
+
reload=reload,
|
|
74
|
+
log_level=log_level,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None:
|
|
79
|
+
"""Parse CLI arguments and invoke the requested subcommand.
|
|
80
|
+
|
|
81
|
+
English:
|
|
82
|
+
Handles ``sshler`` command-line parsing and dispatches to ``serve`` when
|
|
83
|
+
no subcommand is explicitly provided.
|
|
84
|
+
|
|
85
|
+
日本語:
|
|
86
|
+
``sshler`` のコマンドライン引数を解析し、サブコマンドが指定されて
|
|
87
|
+
いない場合は ``serve`` を実行します。
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
parser = argparse.ArgumentParser(prog="sshler", description="Local SSH tmux-in-browser")
|
|
91
|
+
subcommands = parser.add_subparsers(dest="command")
|
|
92
|
+
|
|
93
|
+
serve_parser = subcommands.add_parser("serve", help="Start the sshler web app")
|
|
94
|
+
serve_parser.add_argument(
|
|
95
|
+
"--host",
|
|
96
|
+
default="127.0.0.1",
|
|
97
|
+
help="Interface to bind (default: 127.0.0.1)",
|
|
98
|
+
)
|
|
99
|
+
serve_parser.add_argument("--bind", default=None, help="Alias for --host")
|
|
100
|
+
serve_parser.add_argument("--port", type=int, default=8822)
|
|
101
|
+
serve_parser.add_argument("--reload", action="store_true")
|
|
102
|
+
serve_parser.add_argument(
|
|
103
|
+
"--allow-origin",
|
|
104
|
+
action="append",
|
|
105
|
+
dest="allow_origins",
|
|
106
|
+
default=[],
|
|
107
|
+
help="Allow cross-origin requests from this origin (repeatable)",
|
|
108
|
+
)
|
|
109
|
+
serve_parser.add_argument(
|
|
110
|
+
"--auth",
|
|
111
|
+
help="Enable HTTP basic auth with 'username:password'",
|
|
112
|
+
)
|
|
113
|
+
serve_parser.add_argument(
|
|
114
|
+
"--max-upload-mb",
|
|
115
|
+
type=int,
|
|
116
|
+
default=50,
|
|
117
|
+
help="Maximum upload size in MB (default: 50)",
|
|
118
|
+
)
|
|
119
|
+
serve_parser.add_argument(
|
|
120
|
+
"--no-ssh-alias",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help="Disable SSH config alias expansion",
|
|
123
|
+
)
|
|
124
|
+
serve_parser.add_argument(
|
|
125
|
+
"--log-level",
|
|
126
|
+
default="info",
|
|
127
|
+
choices=["critical", "error", "warning", "info", "debug", "trace"],
|
|
128
|
+
help="Uvicorn log level",
|
|
129
|
+
)
|
|
130
|
+
serve_parser.add_argument("--token", help="Provide a fixed X-SSHLER-TOKEN value")
|
|
131
|
+
serve_parser.add_argument(
|
|
132
|
+
"--no-browser",
|
|
133
|
+
dest="open_browser",
|
|
134
|
+
action="store_false",
|
|
135
|
+
help="Do not automatically open a browser window",
|
|
136
|
+
)
|
|
137
|
+
serve_parser.set_defaults(open_browser=True)
|
|
138
|
+
|
|
139
|
+
parsed_args = parser.parse_args()
|
|
140
|
+
if parsed_args.command in (None, "serve"):
|
|
141
|
+
bind_host = getattr(parsed_args, "bind", None) or getattr(parsed_args, "host", "127.0.0.1")
|
|
142
|
+
basic_auth: tuple[str, str] | None = None
|
|
143
|
+
auth_value = getattr(parsed_args, "auth", None)
|
|
144
|
+
if auth_value:
|
|
145
|
+
if ":" not in auth_value:
|
|
146
|
+
parser.error("--auth must be in the form username:password")
|
|
147
|
+
basic_auth = tuple(auth_value.split(":", 1)) # type: ignore[assignment]
|
|
148
|
+
serve(
|
|
149
|
+
host=bind_host,
|
|
150
|
+
port=getattr(parsed_args, "port", 8822),
|
|
151
|
+
reload=getattr(parsed_args, "reload", False),
|
|
152
|
+
allow_origins=getattr(parsed_args, "allow_origins", []) or [],
|
|
153
|
+
basic_auth=basic_auth,
|
|
154
|
+
max_upload_mb=getattr(parsed_args, "max_upload_mb", 50),
|
|
155
|
+
allow_ssh_alias=not getattr(parsed_args, "no_ssh_alias", False),
|
|
156
|
+
log_level=getattr(parsed_args, "log_level", "info"),
|
|
157
|
+
open_browser=getattr(parsed_args, "open_browser", True),
|
|
158
|
+
token=getattr(parsed_args, "token", None),
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
parser.print_help()
|
sshler/config.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from platformdirs import user_config_dir
|
|
11
|
+
|
|
12
|
+
from . import state
|
|
13
|
+
from .ssh_config import HostConfig, load_ssh_config
|
|
14
|
+
|
|
15
|
+
ENV_CONFIG_DIR = "SSHLER_CONFIG_DIR"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class StoredBox:
|
|
20
|
+
"""User-defined overrides and custom boxes persisted in YAML.
|
|
21
|
+
|
|
22
|
+
English:
|
|
23
|
+
Represents a single box entry saved by the user. Values here override
|
|
24
|
+
hosts discovered from OpenSSH configuration files.
|
|
25
|
+
|
|
26
|
+
日本語:
|
|
27
|
+
ユーザーが保存したボックス定義を表します。OpenSSH の設定から検出した
|
|
28
|
+
ホスト情報に対する上書き値として利用されます。
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
host: str | None = None
|
|
33
|
+
user: str | None = None
|
|
34
|
+
port: int | None = None
|
|
35
|
+
keyfile: str | None = None
|
|
36
|
+
agent: bool = False
|
|
37
|
+
favorites: list[str] = field(default_factory=list)
|
|
38
|
+
default_dir: str | None = None
|
|
39
|
+
known_hosts: str | None = None
|
|
40
|
+
ssh_alias: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Box:
|
|
45
|
+
"""Concrete SSH box presented in the UI after merging sources.
|
|
46
|
+
|
|
47
|
+
English:
|
|
48
|
+
Materialised box configuration shown in the UI after combining SSH
|
|
49
|
+
config values with stored overrides and synthetic entries such as the
|
|
50
|
+
local workspace.
|
|
51
|
+
|
|
52
|
+
日本語:
|
|
53
|
+
SSH 設定、保存済みの上書き、ローカルワークスペースなどを統合した結果を
|
|
54
|
+
UI に表示するための構造体です。
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
connect_host: str
|
|
59
|
+
display_host: str
|
|
60
|
+
user: str
|
|
61
|
+
port: int = 22
|
|
62
|
+
keyfile: str | None = None
|
|
63
|
+
agent: bool = False
|
|
64
|
+
favorites: list[str] = field(default_factory=list)
|
|
65
|
+
default_dir: str | None = None
|
|
66
|
+
known_hosts: str | None = None
|
|
67
|
+
source: str = "custom"
|
|
68
|
+
ssh_alias: str | None = None
|
|
69
|
+
resolved_host: str | None = None
|
|
70
|
+
transport: str = "ssh"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class AppConfig:
|
|
75
|
+
"""Complete configuration containing merged boxes and stored overrides.
|
|
76
|
+
|
|
77
|
+
English:
|
|
78
|
+
In-memory representation of all known boxes plus metadata such as the
|
|
79
|
+
resolved SSH config path.
|
|
80
|
+
|
|
81
|
+
日本語:
|
|
82
|
+
既知のすべてのボックス情報と、解決済みの SSH 設定パスなどのメタデータを
|
|
83
|
+
保持するメモリ上の設定です。
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
boxes: list[Box] = field(default_factory=list)
|
|
87
|
+
stored: dict[str, StoredBox] = field(default_factory=dict)
|
|
88
|
+
ssh_config_path: str | None = None
|
|
89
|
+
|
|
90
|
+
def get_box(self, name: str) -> Box | None:
|
|
91
|
+
for box in self.boxes:
|
|
92
|
+
if box.name == name:
|
|
93
|
+
return box
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def get_or_create_stored(self, name: str) -> StoredBox:
|
|
97
|
+
stored = self.stored.get(name)
|
|
98
|
+
if stored is None:
|
|
99
|
+
stored = StoredBox(name=name)
|
|
100
|
+
self.stored[name] = stored
|
|
101
|
+
return stored
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
DEFAULT_CONFIGURATION_TEMPLATE: dict[str, Any] = {"boxes": []}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_config_dir() -> Path:
|
|
108
|
+
"""Return the configuration directory, creating it when missing.
|
|
109
|
+
|
|
110
|
+
English:
|
|
111
|
+
Determines the directory that will contain ``boxes.yaml`` and creates
|
|
112
|
+
it if necessary.
|
|
113
|
+
|
|
114
|
+
日本語:
|
|
115
|
+
``boxes.yaml`` を格納するディレクトリを決定し、存在しない場合は作成します。
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
override_directory = os.getenv(ENV_CONFIG_DIR)
|
|
119
|
+
if override_directory:
|
|
120
|
+
configuration_dir = Path(override_directory).expanduser()
|
|
121
|
+
else:
|
|
122
|
+
configuration_dir = Path(user_config_dir(appname="sshler", appauthor=False))
|
|
123
|
+
configuration_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
return configuration_dir
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_config_path() -> Path:
|
|
128
|
+
"""Return the path to the boxes configuration file.
|
|
129
|
+
|
|
130
|
+
English:
|
|
131
|
+
Combines :func:`get_config_dir` with ``boxes.yaml`` to produce the full
|
|
132
|
+
configuration filename.
|
|
133
|
+
|
|
134
|
+
日本語:
|
|
135
|
+
:func:`get_config_dir` の結果に ``boxes.yaml`` を連結した設定ファイルのパスを
|
|
136
|
+
返します。
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
return get_config_dir() / "boxes.yaml"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def ensure_config() -> Path:
|
|
143
|
+
"""Create a default configuration file when none exists.
|
|
144
|
+
|
|
145
|
+
English:
|
|
146
|
+
Writes an empty configuration file so later reads never fail because the
|
|
147
|
+
file is missing.
|
|
148
|
+
|
|
149
|
+
日本語:
|
|
150
|
+
設定ファイルが存在しない場合に空のファイルを作成し、読み込みに失敗しない
|
|
151
|
+
ようにします。
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
config_path = get_config_path()
|
|
155
|
+
if not config_path.exists():
|
|
156
|
+
with config_path.open("w", encoding="utf-8") as file_pointer:
|
|
157
|
+
yaml.safe_dump(DEFAULT_CONFIGURATION_TEMPLATE, file_pointer, sort_keys=False)
|
|
158
|
+
return config_path
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_config(ssh_config_path: str | None = None) -> AppConfig:
|
|
162
|
+
"""Load the application configuration from disk and merge SSH config hosts.
|
|
163
|
+
|
|
164
|
+
English:
|
|
165
|
+
Reads ``boxes.yaml``, normalises data, merges it with OpenSSH hosts, and
|
|
166
|
+
returns a populated :class:`AppConfig` including the synthetic local box.
|
|
167
|
+
|
|
168
|
+
日本語:
|
|
169
|
+
``boxes.yaml`` を読み込んで正規化し、OpenSSH のホスト情報と統合したうえで
|
|
170
|
+
ローカルボックスを含む :class:`AppConfig` を返します。
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
config_dir = get_config_dir()
|
|
174
|
+
state.initialize(config_dir)
|
|
175
|
+
config_path = ensure_config()
|
|
176
|
+
with config_path.open("r", encoding="utf-8") as file_pointer:
|
|
177
|
+
raw_data = yaml.safe_load(file_pointer) or {}
|
|
178
|
+
|
|
179
|
+
stored = {}
|
|
180
|
+
for entry in raw_data.get("boxes", []):
|
|
181
|
+
stored_box = _stored_box_from_dict(entry)
|
|
182
|
+
stored[stored_box.name] = stored_box
|
|
183
|
+
|
|
184
|
+
migrated = state.migrate_legacy_favorites(stored)
|
|
185
|
+
_remove_legacy_seed(stored)
|
|
186
|
+
|
|
187
|
+
resolved_path = _resolve_ssh_config_path(ssh_config_path)
|
|
188
|
+
boxes = _build_boxes(stored, load_ssh_config(resolved_path))
|
|
189
|
+
boxes.insert(0, _build_local_box())
|
|
190
|
+
_apply_favorites(boxes)
|
|
191
|
+
app_config = AppConfig(
|
|
192
|
+
boxes=boxes,
|
|
193
|
+
stored=stored,
|
|
194
|
+
ssh_config_path=str(resolved_path) if resolved_path else None,
|
|
195
|
+
)
|
|
196
|
+
if migrated:
|
|
197
|
+
save_config(app_config)
|
|
198
|
+
return app_config
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def save_config(application_config: AppConfig) -> None:
|
|
202
|
+
"""Persist stored overrides to disk.
|
|
203
|
+
|
|
204
|
+
English:
|
|
205
|
+
Serialises the ``stored`` mapping to ``boxes.yaml`` so user-created
|
|
206
|
+
overrides survive restarts.
|
|
207
|
+
|
|
208
|
+
日本語:
|
|
209
|
+
``stored`` マッピングを ``boxes.yaml`` に書き出し、ユーザーが作成した上書き
|
|
210
|
+
設定を永続化します。
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
config_path = get_config_path()
|
|
214
|
+
payload = {
|
|
215
|
+
"boxes": [
|
|
216
|
+
_stored_box_to_dict(stored)
|
|
217
|
+
for stored in sorted(
|
|
218
|
+
application_config.stored.values(), key=lambda item: item.name.lower()
|
|
219
|
+
)
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
with config_path.open("w", encoding="utf-8") as file_pointer:
|
|
223
|
+
yaml.safe_dump(payload, file_pointer, sort_keys=False)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def find_box(application_config: AppConfig, name: str) -> Box | None:
|
|
227
|
+
"""Return the box matching ``name`` when present.
|
|
228
|
+
|
|
229
|
+
English:
|
|
230
|
+
Helper used by request handlers to locate a box by name.
|
|
231
|
+
|
|
232
|
+
日本語:
|
|
233
|
+
リクエストハンドラがボックス名で検索するためのヘルパー関数です。
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
return application_config.get_box(name)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def rebuild_boxes(application_config: AppConfig, ssh_config_path: str | None = None) -> None:
|
|
240
|
+
"""Refresh the merged box list after stored overrides change.
|
|
241
|
+
|
|
242
|
+
English:
|
|
243
|
+
Recomputes the list of concrete boxes based on the latest stored data
|
|
244
|
+
and (optionally) a new SSH config file path.
|
|
245
|
+
|
|
246
|
+
日本語:
|
|
247
|
+
最新の保存情報と SSH 設定パス (必要に応じて) を基に、利用可能なボックスの
|
|
248
|
+
リストを再構築します。
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
state.initialize(get_config_dir())
|
|
252
|
+
resolved_path = _resolve_ssh_config_path(ssh_config_path or application_config.ssh_config_path)
|
|
253
|
+
application_config.ssh_config_path = str(resolved_path) if resolved_path else None
|
|
254
|
+
application_config.boxes = _build_boxes(
|
|
255
|
+
application_config.stored, load_ssh_config(resolved_path)
|
|
256
|
+
)
|
|
257
|
+
_apply_favorites(application_config.boxes)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _apply_favorites(boxes: list[Box]) -> None:
|
|
261
|
+
if not boxes:
|
|
262
|
+
return
|
|
263
|
+
mapping = state.favorites_map([box.name for box in boxes])
|
|
264
|
+
for box in boxes:
|
|
265
|
+
box.favorites = mapping.get(box.name, [])
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _stored_box_from_dict(data: dict[str, Any]) -> StoredBox:
|
|
269
|
+
favorites = data.get("favorites") or []
|
|
270
|
+
return StoredBox(
|
|
271
|
+
name=data["name"],
|
|
272
|
+
host=data.get("host"),
|
|
273
|
+
user=data.get("user"),
|
|
274
|
+
port=int(data["port"]) if "port" in data and data["port"] is not None else None,
|
|
275
|
+
keyfile=data.get("keyfile"),
|
|
276
|
+
agent=bool(data.get("agent", False)),
|
|
277
|
+
favorites=list(favorites),
|
|
278
|
+
default_dir=data.get("default_dir"),
|
|
279
|
+
known_hosts=data.get("known_hosts"),
|
|
280
|
+
ssh_alias=data.get("ssh_alias"),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _stored_box_to_dict(stored: StoredBox) -> dict[str, Any]:
|
|
285
|
+
result: dict[str, Any] = {"name": stored.name}
|
|
286
|
+
if stored.host:
|
|
287
|
+
result["host"] = stored.host
|
|
288
|
+
if stored.user:
|
|
289
|
+
result["user"] = stored.user
|
|
290
|
+
if stored.port is not None:
|
|
291
|
+
result["port"] = int(stored.port)
|
|
292
|
+
if stored.keyfile:
|
|
293
|
+
result["keyfile"] = stored.keyfile
|
|
294
|
+
if stored.agent:
|
|
295
|
+
result["agent"] = stored.agent
|
|
296
|
+
if stored.default_dir:
|
|
297
|
+
result["default_dir"] = stored.default_dir
|
|
298
|
+
if stored.known_hosts:
|
|
299
|
+
result["known_hosts"] = stored.known_hosts
|
|
300
|
+
if stored.ssh_alias:
|
|
301
|
+
result["ssh_alias"] = stored.ssh_alias
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _build_boxes(stored: dict[str, StoredBox], ssh_hosts: dict[str, HostConfig]) -> list[Box]:
|
|
306
|
+
boxes: list[Box] = []
|
|
307
|
+
seen: set[str] = set()
|
|
308
|
+
|
|
309
|
+
for name, host_config in ssh_hosts.items():
|
|
310
|
+
stored_override = stored.get(name)
|
|
311
|
+
boxes.append(_merge_host(name, host_config, stored_override))
|
|
312
|
+
seen.add(name)
|
|
313
|
+
|
|
314
|
+
for name, stored_override in stored.items():
|
|
315
|
+
if name not in seen:
|
|
316
|
+
boxes.append(_merge_host(name, None, stored_override))
|
|
317
|
+
|
|
318
|
+
boxes.sort(key=lambda item: item.name.lower())
|
|
319
|
+
return boxes
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _build_local_box() -> Box:
|
|
323
|
+
home_directory = str(Path.home())
|
|
324
|
+
return Box(
|
|
325
|
+
name="local",
|
|
326
|
+
connect_host="local",
|
|
327
|
+
display_host="localhost",
|
|
328
|
+
user=_default_user(),
|
|
329
|
+
port=0,
|
|
330
|
+
agent=False,
|
|
331
|
+
favorites=[],
|
|
332
|
+
default_dir=home_directory,
|
|
333
|
+
known_hosts=None,
|
|
334
|
+
source="local",
|
|
335
|
+
ssh_alias=None,
|
|
336
|
+
resolved_host=None,
|
|
337
|
+
transport="local",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _merge_host(
|
|
342
|
+
name: str, host_config: HostConfig | None, stored_override: StoredBox | None
|
|
343
|
+
) -> Box:
|
|
344
|
+
if stored_override and stored_override.host:
|
|
345
|
+
connect_host = stored_override.host
|
|
346
|
+
elif host_config and host_config.hostname:
|
|
347
|
+
connect_host = host_config.hostname
|
|
348
|
+
else:
|
|
349
|
+
connect_host = name
|
|
350
|
+
|
|
351
|
+
if stored_override and stored_override.host:
|
|
352
|
+
display_host = stored_override.host
|
|
353
|
+
else:
|
|
354
|
+
display_host = name
|
|
355
|
+
|
|
356
|
+
if stored_override and stored_override.ssh_alias:
|
|
357
|
+
ssh_alias = stored_override.ssh_alias
|
|
358
|
+
else:
|
|
359
|
+
ssh_alias = name
|
|
360
|
+
|
|
361
|
+
resolved_host = host_config.hostname if host_config and host_config.hostname else None
|
|
362
|
+
|
|
363
|
+
base_user = stored_override.user if stored_override and stored_override.user else None
|
|
364
|
+
if base_user is None and host_config and host_config.user:
|
|
365
|
+
base_user = host_config.user
|
|
366
|
+
if base_user is None:
|
|
367
|
+
base_user = _default_user()
|
|
368
|
+
|
|
369
|
+
base_port = stored_override.port if stored_override and stored_override.port else None
|
|
370
|
+
if base_port is None and host_config and host_config.port:
|
|
371
|
+
base_port = host_config.port
|
|
372
|
+
if base_port is None:
|
|
373
|
+
base_port = 22
|
|
374
|
+
|
|
375
|
+
base_keyfile = stored_override.keyfile if stored_override and stored_override.keyfile else None
|
|
376
|
+
if base_keyfile is None and host_config and host_config.identity_files:
|
|
377
|
+
base_keyfile = host_config.identity_files[0]
|
|
378
|
+
|
|
379
|
+
default_dir = stored_override.default_dir if stored_override else None
|
|
380
|
+
known_hosts = stored_override.known_hosts if stored_override else None
|
|
381
|
+
agent = stored_override.agent if stored_override else False
|
|
382
|
+
source = "ssh_config" if host_config else "custom"
|
|
383
|
+
|
|
384
|
+
return Box(
|
|
385
|
+
name=name,
|
|
386
|
+
connect_host=connect_host,
|
|
387
|
+
display_host=display_host,
|
|
388
|
+
user=base_user,
|
|
389
|
+
port=base_port,
|
|
390
|
+
keyfile=base_keyfile,
|
|
391
|
+
agent=agent,
|
|
392
|
+
favorites=[],
|
|
393
|
+
default_dir=default_dir,
|
|
394
|
+
known_hosts=known_hosts,
|
|
395
|
+
source=source,
|
|
396
|
+
ssh_alias=ssh_alias,
|
|
397
|
+
resolved_host=resolved_host,
|
|
398
|
+
transport="ssh",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _default_user() -> str:
|
|
403
|
+
try:
|
|
404
|
+
return getpass.getuser()
|
|
405
|
+
except Exception:
|
|
406
|
+
return ""
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _remove_legacy_seed(stored: dict[str, StoredBox]) -> None:
|
|
410
|
+
legacy = stored.get("gabu-server")
|
|
411
|
+
if not legacy:
|
|
412
|
+
return
|
|
413
|
+
if legacy.host == "example.tailnet.ts.net" and legacy.user == "gabu":
|
|
414
|
+
if legacy.favorites:
|
|
415
|
+
legacy.favorites.clear()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _resolve_ssh_config_path(explicit: str | None = None) -> Path | None:
|
|
419
|
+
if explicit:
|
|
420
|
+
return Path(explicit).expanduser()
|
|
421
|
+
env_override = os.getenv("SSHLER_SSH_CONFIG")
|
|
422
|
+
if env_override:
|
|
423
|
+
return Path(env_override).expanduser()
|
|
424
|
+
default_path = Path.home() / ".ssh" / "config"
|
|
425
|
+
return default_path if default_path.exists() else default_path
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$TaskName = 'sshler',
|
|
3
|
+
[string]$Host = '127.0.0.1',
|
|
4
|
+
[int]$Port = 8822,
|
|
5
|
+
[switch]$Force
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
$ErrorActionPreference = 'Stop'
|
|
9
|
+
|
|
10
|
+
Import-Module ScheduledTasks
|
|
11
|
+
|
|
12
|
+
$repoRoot = Resolve-Path -Path (Join-Path $PSScriptRoot '..')
|
|
13
|
+
$runScript = Resolve-Path -Path (Join-Path $PSScriptRoot 'run-sshler.ps1')
|
|
14
|
+
|
|
15
|
+
$argumentLine = "-NoProfile -ExecutionPolicy Bypass -File `"$runScript`" -Host $Host -Port $Port"
|
|
16
|
+
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argumentLine -WorkingDirectory $repoRoot
|
|
17
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
|
18
|
+
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
|
19
|
+
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest
|
|
20
|
+
|
|
21
|
+
$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings -Principal $principal
|
|
22
|
+
|
|
23
|
+
Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force:$Force.IsPresent | Out-Null
|
|
24
|
+
|
|
25
|
+
Write-Host "Registered scheduled task '$TaskName'.":
|
|
26
|
+
Write-Host " - Host : $Host"
|
|
27
|
+
Write-Host " - Port : $Port"
|
|
28
|
+
Write-Host "Logs will stream to '$(Join-Path $repoRoot "logs")'."
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$TaskName = 'sshler'
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
$ErrorActionPreference = 'Stop'
|
|
6
|
+
|
|
7
|
+
Import-Module ScheduledTasks
|
|
8
|
+
|
|
9
|
+
if (-not (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue)) {
|
|
10
|
+
Write-Host "Scheduled task '$TaskName' was not found."
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
|
15
|
+
Write-Host "Removed scheduled task '$TaskName'."
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$Host = "127.0.0.1",
|
|
3
|
+
[int]$Port = 8822
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = 'Stop'
|
|
7
|
+
|
|
8
|
+
$repoRoot = Resolve-Path -Path (Join-Path $PSScriptRoot "..")
|
|
9
|
+
Set-Location $repoRoot
|
|
10
|
+
|
|
11
|
+
$logDir = Join-Path $repoRoot 'logs'
|
|
12
|
+
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
|
13
|
+
$stdoutLog = Join-Path $logDir 'sshler.stdout.log'
|
|
14
|
+
$stderrLog = Join-Path $logDir 'sshler.stderr.log'
|
|
15
|
+
|
|
16
|
+
$arguments = @('run', 'sshler', 'serve', '--host', $Host, '--port', $Port)
|
|
17
|
+
|
|
18
|
+
Start-Process -FilePath 'uv' `
|
|
19
|
+
-ArgumentList $arguments `
|
|
20
|
+
-WorkingDirectory $repoRoot `
|
|
21
|
+
-NoNewWindow `
|
|
22
|
+
-Wait `
|
|
23
|
+
-RedirectStandardOutput $stdoutLog `
|
|
24
|
+
-RedirectStandardError $stderrLog
|