ssh-server-manager 0.2.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.
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from dataclasses import dataclass
5
+ from typing import Protocol
6
+
7
+
8
+ SERVICE_NAME = "ssh-server-manager"
9
+
10
+
11
+ class VaultError(RuntimeError):
12
+ pass
13
+
14
+
15
+ class VaultProtocol(Protocol):
16
+ def set_secret(self, credential_id: str, slot: str, value: str) -> None: ...
17
+
18
+ def get_secret(self, credential_id: str, slot: str) -> str | None: ...
19
+
20
+ def delete_secret(self, credential_id: str, slot: str) -> None: ...
21
+
22
+ def diagnose(self) -> dict[str, object]: ...
23
+
24
+
25
+ def account_name(credential_id: str, slot: str) -> str:
26
+ if slot not in {"password", "passphrase"}:
27
+ raise VaultError(f"unsupported secret slot: {slot}")
28
+ return f"credential:{credential_id}:{slot}"
29
+
30
+
31
+ class KeyringVault:
32
+ def __init__(self) -> None:
33
+ try:
34
+ import keyring
35
+ from keyring import errors
36
+ except ImportError as exc:
37
+ raise VaultError("Python keyring is not installed; run scripts/bootstrap") from exc
38
+ self.keyring = keyring
39
+ self.errors = errors
40
+ self.backend = keyring.get_keyring()
41
+ self._require_safe_backend()
42
+
43
+ def _require_safe_backend(self) -> None:
44
+ unsafe = ("null", "fail", "plaintext", "keyrings.alt", "filekeyring")
45
+
46
+ def check(backend: object) -> None:
47
+ backend_type = f"{backend.__class__.__module__}.{backend.__class__.__name__}".lower()
48
+ priority = getattr(backend, "priority", 0)
49
+ if any(marker in backend_type for marker in unsafe) or priority <= 0:
50
+ hint = ""
51
+ if sys.platform.startswith("linux"):
52
+ hint = (
53
+ "; on Linux install and unlock a Secret Service provider"
54
+ " (gnome-keyring, KWallet, or KeePassXC with Secret Service enabled)"
55
+ " — see docs/platforms.md for headless servers"
56
+ )
57
+ raise VaultError(f"no safe OS credential backend is available ({backend_type}){hint}")
58
+ for child in getattr(backend, "backends", ()):
59
+ check(child)
60
+
61
+ check(self.backend)
62
+
63
+ def set_secret(self, credential_id: str, slot: str, value: str) -> None:
64
+ if not value:
65
+ raise VaultError("secret must not be empty")
66
+ try:
67
+ self.keyring.set_password(SERVICE_NAME, account_name(credential_id, slot), value)
68
+ except self.errors.KeyringError as exc:
69
+ raise VaultError(f"credential vault rejected the secret: {exc}") from exc
70
+
71
+ def get_secret(self, credential_id: str, slot: str) -> str | None:
72
+ try:
73
+ return self.keyring.get_password(SERVICE_NAME, account_name(credential_id, slot))
74
+ except self.errors.KeyringError as exc:
75
+ raise VaultError(f"credential vault could not read the secret: {exc}") from exc
76
+
77
+ def delete_secret(self, credential_id: str, slot: str) -> None:
78
+ try:
79
+ self.keyring.delete_password(SERVICE_NAME, account_name(credential_id, slot))
80
+ except self.errors.PasswordDeleteError:
81
+ return
82
+ except self.errors.KeyringError as exc:
83
+ raise VaultError(f"credential vault could not delete the secret: {exc}") from exc
84
+
85
+ def diagnose(self) -> dict[str, object]:
86
+ backend_type = f"{self.backend.__class__.__module__}.{self.backend.__class__.__name__}"
87
+ return {"available": True, "backend": backend_type, "priority": getattr(self.backend, "priority", None)}
88
+
89
+
90
+ @dataclass
91
+ class MemoryVault:
92
+ """In-memory vault used only by tests."""
93
+
94
+ values: dict[tuple[str, str], str]
95
+
96
+ def __init__(self) -> None:
97
+ self.values = {}
98
+
99
+ def set_secret(self, credential_id: str, slot: str, value: str) -> None:
100
+ self.values[(credential_id, slot)] = value
101
+
102
+ def get_secret(self, credential_id: str, slot: str) -> str | None:
103
+ return self.values.get((credential_id, slot))
104
+
105
+ def delete_secret(self, credential_id: str, slot: str) -> None:
106
+ self.values.pop((credential_id, slot), None)
107
+
108
+ def diagnose(self) -> dict[str, object]:
109
+ return {"available": True, "backend": "memory-test-only", "priority": 1}
110
+
111
+
112
+ def get_vault() -> VaultProtocol:
113
+ return KeyringVault()
@@ -0,0 +1,377 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import secrets
5
+ import socket
6
+ import stat
7
+ import threading
8
+ import time
9
+ import webbrowser
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .auth import AuthenticationError, RevealAuth
15
+ from .db import ConflictError, Database, DatabaseError, NotFoundError
16
+ from .importer import apply_import, preview_import
17
+ from .paths import asset_dir, managed_ssh_config_path
18
+ from .service import CredentialService
19
+ from .ssh_config import SSHConfigError, render_config
20
+ from .ssh_runner import SSHRunner
21
+ from .validation import ValidationError
22
+ from .vault import VaultError, get_vault
23
+
24
+
25
+ SESSION_COOKIE = "ssm_session"
26
+
27
+
28
+ class UIError(ValidationError):
29
+ """A user-actionable local UI launch error."""
30
+
31
+
32
+ def _select_loopback_port(requested: int) -> int:
33
+ """Resolve a requested port before building the browser origin.
34
+
35
+ Port 0 asks the OS for an unused loopback port. Explicit ports are checked
36
+ early so a bind failure produces a useful CLI error instead of a traceback
37
+ after the one-time URL has already been emitted.
38
+ """
39
+ if not 0 <= requested <= 65535:
40
+ raise UIError("UI port must be between 0 and 65535")
41
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
42
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
43
+ try:
44
+ probe.bind(("127.0.0.1", requested))
45
+ except OSError as exc:
46
+ raise UIError(
47
+ f"UI port {requested} is already in use; retry with --port 0 or choose another port"
48
+ ) from exc
49
+ return int(probe.getsockname()[1])
50
+
51
+
52
+ def _write_private_url(path: Path, url: str) -> Path:
53
+ """Write a one-time launch URL without exposing it in command output."""
54
+ # Keep the final path component intact so an existing symlink cannot be
55
+ # resolved away before O_NOFOLLOW/lstat checks.
56
+ target = Path(os.path.abspath(os.fspath(path.expanduser())))
57
+ target.parent.mkdir(parents=True, exist_ok=True)
58
+ if target.exists() or target.is_symlink():
59
+ info = target.lstat()
60
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
61
+ raise UIError(f"launch URL path is not a regular file: {target}")
62
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
63
+ if hasattr(os, "O_NOFOLLOW"):
64
+ flags |= os.O_NOFOLLOW
65
+ descriptor = os.open(target, flags, 0o600)
66
+ try:
67
+ if hasattr(os, "fchmod"):
68
+ os.fchmod(descriptor, stat.S_IRUSR | stat.S_IWUSR)
69
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
70
+ descriptor = -1
71
+ handle.write(url)
72
+ handle.write("\n")
73
+ finally:
74
+ if descriptor != -1:
75
+ os.close(descriptor)
76
+ if os.name == "nt":
77
+ os.chmod(target, stat.S_IRUSR | stat.S_IWUSR)
78
+ return target
79
+
80
+
81
+ @dataclass
82
+ class BrowserSession:
83
+ csrf: str
84
+ created_at: float = field(default_factory=time.time)
85
+ reveal_grants: dict[str, float] = field(default_factory=dict)
86
+ failed_master_attempts: list[float] = field(default_factory=list)
87
+
88
+
89
+ class WebState:
90
+ def __init__(
91
+ self,
92
+ database: Database,
93
+ *,
94
+ launch_token: str,
95
+ origin: str,
96
+ launch_url_file: Path | None = None,
97
+ ) -> None:
98
+ self.database = database
99
+ self.launch_token = launch_token
100
+ self.launch_token_used = False
101
+ self.launch_url_file = launch_url_file
102
+ self.origin = origin
103
+ self.sessions: dict[str, BrowserSession] = {}
104
+ self.auth = RevealAuth(database, rp_id="localhost", origin=origin)
105
+
106
+ def create_session(self) -> tuple[str, BrowserSession]:
107
+ identifier = secrets.token_urlsafe(32)
108
+ session = BrowserSession(csrf=secrets.token_urlsafe(32))
109
+ self.sessions[identifier] = session
110
+ return identifier, session
111
+
112
+ def clean(self) -> None:
113
+ cutoff = time.time() - 12 * 60 * 60
114
+ self.sessions = {key: value for key, value in self.sessions.items() if value.created_at > cutoff}
115
+
116
+ def consume_launch_url_file(self) -> None:
117
+ if self.launch_url_file is None:
118
+ return
119
+ try:
120
+ self.launch_url_file.unlink(missing_ok=True)
121
+ except OSError:
122
+ pass
123
+
124
+
125
+ def create_app(database: Database, *, launch_token: str, port: int, launch_url_file: Path | None = None):
126
+ try:
127
+ from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response
128
+ from fastapi.responses import FileResponse, JSONResponse
129
+ except ImportError as exc:
130
+ raise RuntimeError("UI dependencies are missing; run scripts/bootstrap") from exc
131
+
132
+ origin = f"http://localhost:{port}"
133
+ state = WebState(database, launch_token=launch_token, origin=origin, launch_url_file=launch_url_file)
134
+ app = FastAPI(title="SSH Server Manager", docs_url=None, redoc_url=None, openapi_url=None)
135
+
136
+ @app.middleware("http")
137
+ async def security_middleware(request: Request, call_next):
138
+ host = request.headers.get("host", "").split(":", 1)[0].strip("[]").casefold()
139
+ if host not in {"localhost", "127.0.0.1", "::1"}:
140
+ return JSONResponse({"error": "invalid host"}, status_code=400)
141
+ request_origin = request.headers.get("origin")
142
+ if request_origin and request_origin != state.origin:
143
+ return JSONResponse({"error": "invalid origin"}, status_code=403)
144
+ response = await call_next(request)
145
+ response.headers["X-Content-Type-Options"] = "nosniff"
146
+ response.headers["X-Frame-Options"] = "DENY"
147
+ response.headers["Referrer-Policy"] = "no-referrer"
148
+ response.headers["Content-Security-Policy"] = (
149
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
150
+ "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
151
+ )
152
+ if request.url.path.startswith("/api/"):
153
+ response.headers["Cache-Control"] = "no-store"
154
+ return response
155
+
156
+ @app.exception_handler(AuthenticationError)
157
+ @app.exception_handler(ValidationError)
158
+ @app.exception_handler(DatabaseError)
159
+ @app.exception_handler(VaultError)
160
+ @app.exception_handler(SSHConfigError)
161
+ async def expected_error(_request: Request, exc: Exception):
162
+ status = 404 if isinstance(exc, NotFoundError) else 409 if isinstance(exc, ConflictError) else 400
163
+ return JSONResponse({"ok": False, "error": exc.__class__.__name__, "message": str(exc)}, status_code=status)
164
+
165
+ def browser_session(request: Request) -> tuple[str, BrowserSession]:
166
+ identifier = request.cookies.get(SESSION_COOKIE)
167
+ session = state.sessions.get(identifier or "")
168
+ if not identifier or not session:
169
+ raise HTTPException(status_code=401, detail="browser session is not initialized")
170
+ return identifier, session
171
+
172
+ async def csrf_session(request: Request) -> tuple[str, BrowserSession]:
173
+ identifier, session = browser_session(request)
174
+ supplied = request.headers.get("x-csrf-token")
175
+ if not supplied or not secrets.compare_digest(supplied, session.csrf):
176
+ raise HTTPException(status_code=403, detail="invalid CSRF token")
177
+ return identifier, session
178
+
179
+ @app.get("/")
180
+ async def index(request: Request, token: str | None = None):
181
+ state.clean()
182
+ identifier = request.cookies.get(SESSION_COOKIE)
183
+ session = state.sessions.get(identifier or "")
184
+ new_identifier = None
185
+ if not session:
186
+ if state.launch_token_used or not token or not secrets.compare_digest(token, state.launch_token):
187
+ raise HTTPException(status_code=401, detail="use the one-time URL opened by serverctl ui")
188
+ state.launch_token_used = True
189
+ state.consume_launch_url_file()
190
+ new_identifier, session = state.create_session()
191
+ response = FileResponse(asset_dir() / "index.html", headers={"Cache-Control": "no-store"})
192
+ if new_identifier:
193
+ response.set_cookie(
194
+ SESSION_COOKIE,
195
+ new_identifier,
196
+ httponly=True,
197
+ samesite="strict",
198
+ secure=False,
199
+ max_age=12 * 60 * 60,
200
+ path="/",
201
+ )
202
+ return response
203
+
204
+ @app.get("/assets/{filename}")
205
+ async def assets(filename: str, _session=Depends(browser_session)):
206
+ if filename not in {"app.js", "styles.css"}:
207
+ raise HTTPException(status_code=404)
208
+ return FileResponse(asset_dir() / filename)
209
+
210
+ @app.get("/api/bootstrap")
211
+ async def bootstrap(session_data=Depends(browser_session)):
212
+ _identifier, session = session_data
213
+ return {
214
+ "csrf": session.csrf,
215
+ "servers": database.list_servers(),
216
+ "credentials": database.list_credentials(),
217
+ "auth": state.auth.status(),
218
+ "managed_config": str(managed_ssh_config_path()),
219
+ }
220
+
221
+ @app.post("/api/servers")
222
+ async def add_server(payload: dict[str, Any] = Body(...), _session=Depends(csrf_session)):
223
+ result = database.create_server(**payload)
224
+ render_config(database)
225
+ return result
226
+
227
+ @app.put("/api/servers/{identifier}")
228
+ async def update_server(identifier: str, payload: dict[str, Any] = Body(...), _session=Depends(csrf_session)):
229
+ result = database.update_server(identifier, **payload)
230
+ render_config(database)
231
+ return result
232
+
233
+ @app.delete("/api/servers/{identifier}")
234
+ async def remove_server(identifier: str, _session=Depends(csrf_session)):
235
+ result = database.delete_server(identifier)
236
+ render_config(database)
237
+ return result
238
+
239
+ @app.post("/api/servers/{identifier}/test")
240
+ async def test_server(identifier: str, _session=Depends(csrf_session)):
241
+ return await asyncio.to_thread(SSHRunner(database).test, identifier)
242
+
243
+ @app.post("/api/import/preview")
244
+ async def import_preview(payload: dict[str, Any] = Body(default={}), _session=Depends(csrf_session)):
245
+ return await asyncio.to_thread(preview_import, database, config=payload.get("config"))
246
+
247
+ @app.post("/api/import/apply")
248
+ async def import_apply(payload: dict[str, Any] = Body(...), _session=Depends(csrf_session)):
249
+ preview = await asyncio.to_thread(preview_import, database, config=payload.get("config"))
250
+ result = apply_import(database, preview, overwrite=bool(payload.get("overwrite")))
251
+ render_config(database)
252
+ return result
253
+
254
+ @app.post("/api/credentials")
255
+ async def add_credential(payload: dict[str, Any] = Body(...), _session=Depends(csrf_session)):
256
+ service = CredentialService(database, get_vault())
257
+ kind = payload.get("kind")
258
+ if kind == "password":
259
+ result = service.create_password(payload.get("label", ""), payload.get("secret", ""))
260
+ elif kind == "key":
261
+ result = service.create_key(
262
+ payload.get("label", ""), payload.get("key_path", ""), payload.get("passphrase") or None
263
+ )
264
+ elif kind == "agent":
265
+ result = service.create_agent(payload.get("label", ""))
266
+ else:
267
+ raise ValidationError("unsupported credential kind")
268
+ render_config(database)
269
+ return result
270
+
271
+ @app.put("/api/credentials/{identifier}")
272
+ async def update_credential(
273
+ identifier: str, payload: dict[str, Any] = Body(...), _session=Depends(csrf_session)
274
+ ):
275
+ service = CredentialService(database, get_vault())
276
+ result = service.update(
277
+ identifier,
278
+ label=payload.get("label"),
279
+ key_path=payload.get("key_path"),
280
+ secret=payload.get("secret"),
281
+ passphrase=payload.get("passphrase"),
282
+ clear_passphrase=bool(payload.get("clear_passphrase")),
283
+ )
284
+ render_config(database)
285
+ return result
286
+
287
+ @app.delete("/api/credentials/{identifier}")
288
+ async def remove_credential(identifier: str, _session=Depends(csrf_session)):
289
+ result = CredentialService(database, get_vault()).delete(identifier)
290
+ render_config(database)
291
+ return result
292
+
293
+ @app.post("/api/auth/passkey/register/options")
294
+ async def passkey_register_options(session_data=Depends(csrf_session)):
295
+ identifier, _session = session_data
296
+ return state.auth.begin_registration(identifier)
297
+
298
+ @app.post("/api/auth/passkey/register/verify")
299
+ async def passkey_register_verify(
300
+ payload: dict[str, Any] = Body(...), session_data=Depends(csrf_session)
301
+ ):
302
+ identifier, _session = session_data
303
+ return state.auth.finish_registration(identifier, payload)
304
+
305
+ @app.post("/api/auth/master/enroll")
306
+ async def master_enroll(payload: dict[str, Any] = Body(...), _session=Depends(csrf_session)):
307
+ return state.auth.enroll_master_password(payload.get("password", ""))
308
+
309
+ @app.post("/api/auth/reveal/options")
310
+ async def reveal_options(payload: dict[str, Any] = Body(...), session_data=Depends(csrf_session)):
311
+ identifier, _session = session_data
312
+ database.get_credential(payload.get("credential_id", ""))
313
+ return state.auth.begin_authentication(identifier)
314
+
315
+ @app.post("/api/auth/reveal/verify")
316
+ async def reveal_verify(payload: dict[str, Any] = Body(...), session_data=Depends(csrf_session)):
317
+ identifier, session = session_data
318
+ credential_id = payload.get("credential_id", "")
319
+ state.auth.finish_authentication(identifier, payload.get("response", {}))
320
+ session.reveal_grants[credential_id] = time.time() + 30
321
+ return {"ok": True}
322
+
323
+ @app.post("/api/auth/master/verify")
324
+ async def master_verify(payload: dict[str, Any] = Body(...), session_data=Depends(csrf_session)):
325
+ _identifier, session = session_data
326
+ now = time.time()
327
+ session.failed_master_attempts = [item for item in session.failed_master_attempts if item > now - 60]
328
+ if len(session.failed_master_attempts) >= 5:
329
+ raise AuthenticationError("too many failed attempts; wait one minute")
330
+ if not state.auth.verify_master_password(payload.get("password", "")):
331
+ session.failed_master_attempts.append(now)
332
+ raise AuthenticationError("master password is incorrect")
333
+ credential_id = payload.get("credential_id", "")
334
+ database.get_credential(credential_id)
335
+ session.reveal_grants[credential_id] = now + 30
336
+ return {"ok": True}
337
+
338
+ @app.post("/api/credentials/{identifier}/reveal")
339
+ async def reveal_credential(identifier: str, session_data=Depends(csrf_session)):
340
+ _session_id, session = session_data
341
+ expires = session.reveal_grants.pop(identifier, 0)
342
+ if expires < time.time():
343
+ raise AuthenticationError("reauthentication is required")
344
+ result = CredentialService(database, get_vault()).reveal(identifier)
345
+ return JSONResponse(result, headers={"Cache-Control": "no-store"})
346
+
347
+ return app
348
+
349
+
350
+ def run_ui(
351
+ *, database: Database, port: int = 0, open_browser: bool = True, url_file: Path | None = None
352
+ ) -> None:
353
+ try:
354
+ import uvicorn
355
+ except ImportError as exc:
356
+ raise RuntimeError("UI dependencies are missing; run scripts/bootstrap") from exc
357
+ if not open_browser and url_file is None:
358
+ raise UIError("--no-open requires --url-file so the one-time URL stays out of terminal output")
359
+ actual_port = _select_loopback_port(port)
360
+ launch_token = secrets.token_urlsafe(32)
361
+ url = f"http://localhost:{actual_port}/?token={launch_token}"
362
+ if url_file is not None:
363
+ written = _write_private_url(url_file, url)
364
+ print(f"SSH Server Manager launch URL written to {written}", flush=True)
365
+ elif open_browser:
366
+ print(f"SSH Server Manager opening in your browser on port {actual_port}", flush=True)
367
+ if open_browser:
368
+ threading.Timer(0.8, lambda: webbrowser.open(url)).start()
369
+ app = create_app(database, launch_token=launch_token, port=actual_port, launch_url_file=url_file)
370
+ try:
371
+ uvicorn.run(app, host="127.0.0.1", port=actual_port, log_level="warning", access_log=False)
372
+ finally:
373
+ if url_file is not None:
374
+ try:
375
+ url_file.unlink(missing_ok=True)
376
+ except OSError:
377
+ pass
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: ssh-server-manager
3
+ Version: 0.2.0
4
+ Summary: Local-first SSH host and credential manager for humans and AI agents — OS-keychain vault, loopback web UI, agent-safe by design
5
+ License: MIT
6
+ Project-URL: Homepage, https://xiayh0107.github.io/servers-connect/
7
+ Project-URL: Repository, https://github.com/xiayh0107/servers-connect
8
+ Project-URL: Documentation, https://github.com/xiayh0107/servers-connect/tree/main/ssh-server-manager/docs
9
+ Project-URL: Changelog, https://github.com/xiayh0107/servers-connect/blob/main/ssh-server-manager/CHANGELOG.md
10
+ Keywords: ssh,credentials,keychain,keyring,agent-skill,devops
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Topic :: System :: Systems Administration
21
+ Classifier: Topic :: Security
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: argon2-cffi>=23.1
25
+ Requires-Dist: fastapi>=0.115
26
+ Requires-Dist: keyring>=25.0
27
+ Requires-Dist: platformdirs>=4.0
28
+ Requires-Dist: uvicorn>=0.34
29
+ Requires-Dist: webauthn>=2.2
30
+ Provides-Extra: test
31
+ Requires-Dist: httpx>=0.28; extra == "test"
32
+ Requires-Dist: pytest>=8.0; extra == "test"
33
+
34
+ # SSH Server Manager
35
+
36
+ **A local-first SSH host and credential manager for humans and AI agents.**
37
+ One CLI (`serverctl`) and a loopback-only web UI to add, import, test, and
38
+ connect to your servers — with every password and passphrase stored in your
39
+ operating system's native credential vault, never in a plaintext file.
40
+
41
+ [![test](https://github.com/xiayh0107/servers-connect/actions/workflows/test.yml/badge.svg)](https://github.com/xiayh0107/servers-connect/actions/workflows/test.yml)
42
+ [![release](https://img.shields.io/github/v/release/xiayh0107/servers-connect)](https://github.com/xiayh0107/servers-connect/releases)
43
+ [![license](https://img.shields.io/badge/license-MIT-green.svg)](../LICENSE)
44
+
45
+ [简体中文说明](README.zh-CN.md) · [Documentation](docs/) · [Security model](docs/security.md) · [Website](https://xiayh0107.github.io/servers-connect/)
46
+
47
+ > 🤖 **Using an AI agent?** The skill deploys to every agent on your machine
48
+ > in one line — dependencies, links, and health check included. See the
49
+ > [agent deployment guide](docs/ai-agents.md).
50
+
51
+ ---
52
+
53
+ ## Why this exists
54
+
55
+ Most SSH managers make you choose between convenience and custody:
56
+
57
+ - **Cloud-synced clients** (subscription GUIs) keep your credentials on
58
+ someone else's infrastructure.
59
+ - **Plain `~/.ssh/config`** keeps you in control but handles no passwords,
60
+ no vault, and gives AI coding agents an easy way to leak secrets.
61
+ - **`sshpass`-style wrappers** put passwords in files, env vars, or shell
62
+ history.
63
+
64
+ SSH Server Manager is different by construction:
65
+
66
+ | Property | How |
67
+ |---|---|
68
+ | Secrets never touch disk in plaintext | macOS Keychain / Windows Credential Locker / Linux Secret Service via `keyring`; unsafe backends are rejected as a hard error |
69
+ | Your `~/.ssh/config` is never modified | A separate managed config is rendered and included alongside it |
70
+ | The web UI is unreachable from the network | Loopback-only bind, one-time launch token, CSRF + Origin checks, strict CSP |
71
+ | Revealing a stored secret requires re-auth | WebAuthn passkey (Touch ID / Windows Hello) or an Argon2id master password; grants are single-use and expire in 30 s |
72
+ | AI agents can drive it without seeing secrets | Every command has `--json`; SSH authentication happens through AskPass so secrets never appear in argv, env, logs, or model context |
73
+ | Host keys are always verified | `StrictHostKeyChecking` is never weakened, by policy and by code |
74
+
75
+ ## Quick start
76
+
77
+ ```bash
78
+ git clone https://github.com/xiayh0107/servers-connect.git
79
+ cd servers-connect/ssh-server-manager
80
+ ./scripts/bootstrap # Windows: scripts\bootstrap.cmd
81
+ ./scripts/serverctl doctor # verify ssh, vault backend, and dependencies
82
+ ```
83
+
84
+ Add a server and connect:
85
+
86
+ ```bash
87
+ ./scripts/serverctl credential add-password work-password # prompts locally, stores in OS vault
88
+ ./scripts/serverctl server add web1 --hostname web1.example.com --username deploy --credential work-password
89
+ ./scripts/serverctl server test web1
90
+ ./scripts/serverctl connect web1
91
+ ```
92
+
93
+ Or import everything you already have:
94
+
95
+ ```bash
96
+ ./scripts/serverctl server import # preview only
97
+ ./scripts/serverctl server import --apply
98
+ ```
99
+
100
+ Prefer a GUI? `./scripts/serverctl ui` opens the local web interface in your
101
+ browser with a one-time tokenized URL.
102
+
103
+ ## Features
104
+
105
+ - **Connection profiles** — alias, host, port, user, notes, ordered
106
+ ProxyJump chains (with cycle detection).
107
+ - **Three credential kinds** — vault-backed passwords, private keys with
108
+ optional vault-backed passphrases, and ssh-agent/OpenSSH defaults.
109
+ Credentials are reusable across servers and protected against deletion
110
+ while referenced.
111
+ - **OpenSSH import** — parses your `~/.ssh/config` (following `Include`),
112
+ resolves each literal alias with `ssh -G`, previews before applying.
113
+ - **Managed config rendering** — atomic, user-only-permission writes to
114
+ `~/.ssh/ssh-server-manager.conf`; your original config always loads last
115
+ so unrelated defaults keep working.
116
+ - **Connection testing** — `server test` reports latency and a classified
117
+ error code (`authentication-failed`, `host-key-untrusted`, `timeout`,
118
+ `dns-failed`, …) and records history.
119
+ - **Remote execution** — `serverctl exec alias -- cmd args`, with `--shell`
120
+ for pipelines, `--stdin`/`--stdin-binary` for streaming files, `--reuse N`
121
+ for ControlMaster connection sharing (macOS/Linux), and `--json` for
122
+ machine-readable results.
123
+ - **Local web UI** — manage servers and credentials, test connections,
124
+ import config, and reveal a stored secret after passkey or master-password
125
+ re-authentication.
126
+ - **Diagnostics** — `serverctl doctor` checks ssh availability and version,
127
+ vault backend safety, database and config paths, and Python dependencies.
128
+
129
+ ## Platform support
130
+
131
+ | | macOS | Linux | Windows |
132
+ |---|---|---|---|
133
+ | CLI + web UI | ✅ | ✅ | ✅ |
134
+ | Credential vault | Keychain | Secret Service (gnome-keyring / KWallet / KeePassXC) | Credential Locker |
135
+ | Secret reveal re-auth | Touch ID passkey or master password | passkey or master password | Windows Hello passkey or master password |
136
+ | Connection reuse (`--reuse`) | ✅ | ✅ | — (OpenSSH ControlMaster needs Unix sockets; a warning is printed) |
137
+
138
+ Details and headless-server notes: [docs/platforms.md](docs/platforms.md).
139
+ CI runs the full test suite on all three platforms.
140
+
141
+ ## For AI agents
142
+
143
+ This project ships as an Agent Skill ([SKILL.md](SKILL.md)) so Claude Code,
144
+ Codex, and other agents can manage servers safely: agents get structured
145
+ JSON output and connection error classification, while the AskPass
146
+ architecture keeps secrets out of the model's context by design.
147
+
148
+ Deploy the skill with one line:
149
+
150
+ ```bash
151
+ curl -fsSL https://raw.githubusercontent.com/xiayh0107/servers-connect/main/install.sh | sh
152
+ ```
153
+
154
+ It links the skill into every detected agent (`~/.claude/skills`,
155
+ `~/.codex/skills`, …), installs dependencies, and runs `doctor`. Windows
156
+ uses `install.ps1`. See [docs/ai-agents.md](docs/ai-agents.md).
157
+
158
+ ## Documentation
159
+
160
+ | Doc | Contents |
161
+ |---|---|
162
+ | [docs/installation.md](docs/installation.md) | Per-platform install, requirements, bootstrap |
163
+ | [docs/quickstart.md](docs/quickstart.md) | First 10 minutes, common workflows |
164
+ | [docs/cli.md](docs/cli.md) | Complete `serverctl` reference |
165
+ | [docs/web-ui.md](docs/web-ui.md) | Web UI walkthrough and its security gates |
166
+ | [docs/security.md](docs/security.md) | Threat model and security invariants |
167
+ | [docs/platforms.md](docs/platforms.md) | Platform-specific behavior, headless Linux |
168
+ | [docs/ai-agents.md](docs/ai-agents.md) | Using with Claude Code / Codex / MCP |
169
+ | [docs/faq.md](docs/faq.md) | Troubleshooting and common questions |
170
+
171
+ ## License
172
+
173
+ [MIT](../LICENSE)