ssh-docs 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.
ssh_docs/server.py ADDED
@@ -0,0 +1,234 @@
1
+ """SSH server implementation for SSH-Docs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import asyncssh
11
+
12
+ from .config import Config
13
+ from .shell import SSHDocsShell
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class SSHDocsServer:
20
+ """SSH server that provides documentation browsing capabilities."""
21
+
22
+ def __init__(self, config: Config) -> None:
23
+ self.config = config
24
+ self.content_root = config.content_root.resolve()
25
+ self.server: Optional[asyncssh.SSHAcceptor] = None
26
+
27
+ # Ensure content root exists
28
+ if not self.content_root.exists():
29
+ raise ValueError(f"Content root does not exist: {self.content_root}")
30
+ if not self.content_root.is_dir():
31
+ raise ValueError(f"Content root is not a directory: {self.content_root}")
32
+
33
+ async def start(self) -> None:
34
+ """Start the SSH server."""
35
+ # Generate or load host key
36
+ host_key = await self._get_host_key()
37
+
38
+ # Configure server options
39
+ server_options = {
40
+ "server_host_keys": [host_key],
41
+ "process_factory": self._create_process,
42
+ "session_factory": None, # We handle sessions via process_factory
43
+ "encoding": None, # Handle encoding in shell
44
+ }
45
+
46
+ # Add authentication if configured
47
+ if self.config.auth_type == "password" and self.config.password:
48
+ server_options["password_auth"] = True
49
+
50
+ elif self.config.auth_type == "key" and self.config.authorized_keys:
51
+ server_options["authorized_client_keys"] = self.config.authorized_keys
52
+
53
+ else:
54
+ # Public access - accept any connection
55
+ server_options["password_auth"] = False
56
+ server_options["public_key_auth"] = False
57
+
58
+ logger.info(f"Starting SSH server on {self.config.host}:{self.config.port}")
59
+ logger.info(f"Content root: {self.content_root}")
60
+ logger.info(f"Site name: {self.config.site_name}")
61
+ logger.info(f"Auth type: {self.config.auth_type}")
62
+
63
+ try:
64
+ self.server = await asyncssh.create_server(
65
+ lambda: SSHDocsServerProtocol(self),
66
+ host=self.config.host,
67
+ port=self.config.port,
68
+ **server_options,
69
+ )
70
+
71
+ logger.info("SSH server started successfully")
72
+
73
+ except Exception as e:
74
+ logger.error(f"Failed to start SSH server: {e}")
75
+ raise
76
+
77
+ async def stop(self) -> None:
78
+ """Stop the SSH server."""
79
+ if self.server:
80
+ self.server.close()
81
+ await self.server.wait_closed()
82
+ logger.info("SSH server stopped")
83
+
84
+ def _create_process(self) -> SSHDocsProcess:
85
+ """Factory method to create a new process for each connection."""
86
+ return SSHDocsProcess(self)
87
+
88
+ async def _get_host_key(self) -> str:
89
+ """Get or generate SSH host key."""
90
+ if self.config.host_key and self.config.host_key.exists():
91
+ logger.info(f"Using host key from: {self.config.host_key}")
92
+ return str(self.config.host_key)
93
+
94
+ # Generate temporary key
95
+ logger.info("Generating temporary host key")
96
+ key = asyncssh.generate_private_key("ssh-rsa")
97
+
98
+ # Save to default location if possible
99
+ key_dir = Path.home() / ".ssh-docs" / "keys"
100
+ key_dir.mkdir(parents=True, exist_ok=True)
101
+ key_path = key_dir / "ssh_host_rsa_key"
102
+
103
+ try:
104
+ key.write_private_key(str(key_path))
105
+ logger.info(f"Saved host key to: {key_path}")
106
+ except Exception as e:
107
+ logger.warning(f"Could not save host key: {e}")
108
+
109
+ return key
110
+
111
+
112
+ class SSHDocsServerProtocol(asyncssh.SSHServer):
113
+ """SSH server protocol handler."""
114
+
115
+ def __init__(self, server: SSHDocsServer) -> None:
116
+ self.server = server
117
+
118
+ def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
119
+ """Called when a connection is established."""
120
+ peer = conn.get_extra_info("peername")
121
+ logger.info(f"Connection from {peer}")
122
+
123
+ def connection_lost(self, exc: Optional[Exception]) -> None:
124
+ """Called when connection is lost."""
125
+ if exc:
126
+ logger.error(f"Connection lost with error: {exc}")
127
+ else:
128
+ logger.info("Connection closed")
129
+
130
+ def begin_auth(self, username: str) -> bool:
131
+ """Begin authentication for a user."""
132
+ logger.debug(f"Authentication attempt for user: {username}")
133
+
134
+ # For public access, accept any username
135
+ if self.server.config.auth_type == "public":
136
+ return True
137
+
138
+ return True
139
+
140
+ def password_auth_supported(self) -> bool:
141
+ """Check if password authentication is supported."""
142
+ return self.server.config.auth_type == "password"
143
+
144
+ def validate_password(self, username: str, password: str) -> bool:
145
+ """Validate password for a user."""
146
+ if self.server.config.auth_type != "password":
147
+ return False
148
+
149
+ if not self.server.config.password:
150
+ return False
151
+
152
+ return password == self.server.config.password
153
+
154
+
155
+ class SSHDocsProcess(asyncssh.SSHServerProcess):
156
+ """Process handler for SSH sessions."""
157
+
158
+ def __init__(self, server: SSHDocsServer) -> None:
159
+ self.server = server
160
+ self.shell: Optional[SSHDocsShell] = None
161
+
162
+ def connection_made(self, chan: asyncssh.SSHServerChannel) -> None:
163
+ """Called when channel is opened."""
164
+ self.chan = chan
165
+
166
+ def shell_requested(self) -> bool:
167
+ """Handle shell request."""
168
+ return True
169
+
170
+ def session_started(self) -> None:
171
+ """Called when session starts."""
172
+ # Create shell instance
173
+ self.shell = SSHDocsShell(
174
+ stdin=self.stdin,
175
+ stdout=self.stdout,
176
+ stderr=self.stderr,
177
+ content_root=self.server.content_root,
178
+ site_name=self.server.config.site_name,
179
+ banner=self.server.config.banner,
180
+ )
181
+
182
+ # Start shell in background task
183
+ asyncio.create_task(self._run_shell())
184
+
185
+ async def _run_shell(self) -> None:
186
+ """Run the shell session."""
187
+ try:
188
+ await self.shell.run()
189
+ except Exception as e:
190
+ logger.error(f"Shell error: {e}")
191
+ finally:
192
+ self.exit(0)
193
+
194
+ def break_received(self, msec: int) -> bool:
195
+ """Handle break signal."""
196
+ return True
197
+
198
+ def signal_received(self, signal: str) -> None:
199
+ """Handle signal."""
200
+ logger.debug(f"Received signal: {signal}")
201
+
202
+
203
+ async def run_server(config: Config) -> None:
204
+ """Run the SSH server (convenience function).
205
+
206
+ Args:
207
+ config: Server configuration.
208
+ """
209
+ # Setup logging
210
+ log_level = getattr(logging, config.log_level.upper(), logging.INFO)
211
+ logging.basicConfig(
212
+ level=log_level,
213
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
214
+ filename=config.log_file if config.log_file else None,
215
+ )
216
+
217
+ server = SSHDocsServer(config)
218
+
219
+ try:
220
+ await server.start()
221
+
222
+ # Keep server running
223
+ print(f"SSH-Docs server running on {config.host}:{config.port}")
224
+ print(f"Content: {config.content_root}")
225
+ print(f"Connect with: ssh {config.host} -p {config.port}")
226
+ print("Press Ctrl+C to stop")
227
+
228
+ # Wait indefinitely
229
+ await asyncio.Event().wait()
230
+
231
+ except KeyboardInterrupt:
232
+ print("\nShutting down...")
233
+ finally:
234
+ await server.stop()
ssh_docs/shell.py ADDED
@@ -0,0 +1,307 @@
1
+ """Interactive shell session for SSH-Docs server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shlex
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Optional
10
+
11
+
12
+ class SSHDocsShell:
13
+ """Interactive shell session that provides Unix-like commands for browsing documentation."""
14
+
15
+ def __init__(
16
+ self,
17
+ stdin: Any,
18
+ stdout: Any,
19
+ stderr: Any,
20
+ content_root: Path,
21
+ site_name: str,
22
+ banner: Optional[str] = None,
23
+ ) -> None:
24
+ self.stdin = stdin
25
+ self.stdout = stdout
26
+ self.stderr = stderr
27
+ self.content_root = content_root.resolve()
28
+ self.site_name = site_name
29
+ self.cwd = "/site"
30
+ self.banner = banner or self._default_banner()
31
+
32
+ def _default_banner(self) -> str:
33
+ return f"""Connected to {self.site_name}.ssh-docs
34
+ Source root: {self.content_root}
35
+ Mounted content: /site
36
+ Supported commands: pwd, ls, cd, cat, head, tail, find, grep, help, exit
37
+ Readonly session
38
+
39
+ """
40
+
41
+ async def run(self) -> None:
42
+ """Main command loop."""
43
+ self.stdout.write(self.banner)
44
+
45
+ while True:
46
+ try:
47
+ self.stdout.write(f"{self.cwd}$ ")
48
+ line = await self.stdin.readline()
49
+ if not line:
50
+ break
51
+
52
+ raw = line.strip()
53
+ if not raw:
54
+ continue
55
+
56
+ try:
57
+ parts = shlex.split(raw)
58
+ except ValueError as exc:
59
+ self.stdout.write(f"parse error: {exc}\n")
60
+ continue
61
+
62
+ cmd = parts[0]
63
+ args = parts[1:]
64
+
65
+ if cmd in {"exit", "quit"}:
66
+ self.stdout.write("Session closed\n")
67
+ break
68
+
69
+ await self._execute_command(cmd, args)
70
+
71
+ except (EOFError, KeyboardInterrupt):
72
+ self.stdout.write("\nSession closed\n")
73
+ break
74
+
75
+ async def _execute_command(self, cmd: str, args: list[str]) -> None:
76
+ """Execute a single command."""
77
+ commands: dict[str, Callable] = {
78
+ "help": self.cmd_help,
79
+ "pwd": self.cmd_pwd,
80
+ "ls": self.cmd_ls,
81
+ "cd": self.cmd_cd,
82
+ "cat": self.cmd_cat,
83
+ "head": self.cmd_head,
84
+ "tail": self.cmd_tail,
85
+ "find": self.cmd_find,
86
+ "grep": self.cmd_grep,
87
+ }
88
+
89
+ if cmd in commands:
90
+ await commands[cmd](args)
91
+ else:
92
+ self.stdout.write(f"unsupported command: {cmd}\n")
93
+
94
+ def _resolve_virtual_path(self, value: Optional[str]) -> str:
95
+ """Resolve a path argument to a normalized virtual path."""
96
+ if not value:
97
+ return self.cwd
98
+ candidate = value if value.startswith("/") else str(Path(self.cwd) / value)
99
+ normalized = os.path.normpath(candidate).replace("\\", "/")
100
+ if not normalized.startswith("/site"):
101
+ return "/invalid"
102
+ return normalized
103
+
104
+ def _to_real_path(self, virtual_path: str) -> Optional[Path]:
105
+ """Convert virtual path to real filesystem path with security checks."""
106
+ if not virtual_path.startswith("/site"):
107
+ return None
108
+ rel = virtual_path.removeprefix("/site").lstrip("/")
109
+ target = (self.content_root / rel).resolve()
110
+ try:
111
+ target.relative_to(self.content_root)
112
+ except ValueError:
113
+ return None
114
+ return target
115
+
116
+ def _to_virtual_path(self, path: Path) -> str:
117
+ """Convert real filesystem path to virtual path."""
118
+ rel = path.relative_to(self.content_root)
119
+ rel_str = str(rel).replace("\\", "/")
120
+ return "/site" if rel_str == "." else f"/site/{rel_str}"
121
+
122
+ async def cmd_help(self, args: list[str]) -> None:
123
+ """Display available commands."""
124
+ self.stdout.write("Commands: pwd, ls, cd, cat, head, tail, find, grep, help, exit\n")
125
+
126
+ async def cmd_pwd(self, args: list[str]) -> None:
127
+ """Print current working directory."""
128
+ self.stdout.write(f"{self.cwd}\n")
129
+
130
+ async def cmd_ls(self, args: list[str]) -> None:
131
+ """List directory contents."""
132
+ virtual_path = self._resolve_virtual_path(args[0] if args else None)
133
+ real_path = self._to_real_path(virtual_path)
134
+
135
+ if virtual_path == "/invalid" or real_path is None or not real_path.exists():
136
+ self.stdout.write(f"ls: no such file or directory: {virtual_path}\n")
137
+ return
138
+
139
+ if real_path.is_file():
140
+ self.stdout.write(f"{real_path.name}\n")
141
+ return
142
+
143
+ for child in sorted(real_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
144
+ self.stdout.write(f"{child.name}\n")
145
+
146
+ async def cmd_cd(self, args: list[str]) -> None:
147
+ """Change current directory."""
148
+ virtual_path = self._resolve_virtual_path(args[0] if args else "/site")
149
+ real_path = self._to_real_path(virtual_path)
150
+
151
+ if virtual_path == "/invalid" or real_path is None or not real_path.exists():
152
+ self.stdout.write(f"cd: no such file or directory: {virtual_path}\n")
153
+ return
154
+
155
+ if not real_path.is_dir():
156
+ self.stdout.write(f"cd: not a directory: {virtual_path}\n")
157
+ return
158
+
159
+ self.cwd = virtual_path
160
+
161
+ async def cmd_cat(self, args: list[str]) -> None:
162
+ """Display file contents."""
163
+ file_path = await self._require_file_arg("cat", args)
164
+ if file_path is None:
165
+ return
166
+
167
+ try:
168
+ content = file_path.read_text(encoding="utf-8")
169
+ self.stdout.write(content.rstrip() + "\n")
170
+ except UnicodeDecodeError:
171
+ self.stdout.write("cat: cannot read binary file\n")
172
+
173
+ async def cmd_head(self, args: list[str]) -> None:
174
+ """Display first lines of a file."""
175
+ await self._print_slice("head", args, tail=False)
176
+
177
+ async def cmd_tail(self, args: list[str]) -> None:
178
+ """Display last lines of a file."""
179
+ await self._print_slice("tail", args, tail=True)
180
+
181
+ async def _print_slice(self, command: str, args: list[str], tail: bool) -> None:
182
+ """Helper for head/tail commands."""
183
+ if not args:
184
+ self.stdout.write(f"{command}: missing file operand\n")
185
+ return
186
+
187
+ count = 10
188
+ file_index = 0
189
+
190
+ if len(args) >= 2 and args[0] == "-n":
191
+ try:
192
+ count = int(args[1])
193
+ except ValueError:
194
+ self.stdout.write(f"{command}: invalid line count\n")
195
+ return
196
+ file_index = 2
197
+
198
+ if file_index >= len(args):
199
+ self.stdout.write(f"{command}: missing file operand\n")
200
+ return
201
+
202
+ file_path = await self._require_file_arg(command, [args[file_index]])
203
+ if file_path is None:
204
+ return
205
+
206
+ try:
207
+ lines = file_path.read_text(encoding="utf-8").splitlines()
208
+ selected = lines[-count:] if tail else lines[:count]
209
+ self.stdout.write("\n".join(selected) + "\n")
210
+ except UnicodeDecodeError:
211
+ self.stdout.write(f"{command}: cannot read binary file\n")
212
+
213
+ async def _require_file_arg(self, command: str, args: list[str]) -> Optional[Path]:
214
+ """Validate and return file path from arguments."""
215
+ if not args:
216
+ self.stdout.write(f"{command}: missing file operand\n")
217
+ return None
218
+
219
+ virtual_path = self._resolve_virtual_path(args[0])
220
+ real_path = self._to_real_path(virtual_path)
221
+
222
+ if virtual_path == "/invalid" or real_path is None or not real_path.exists():
223
+ self.stdout.write(f"{command}: no such file: {virtual_path}\n")
224
+ return None
225
+
226
+ if not real_path.is_file():
227
+ self.stdout.write(f"{command}: is a directory: {virtual_path}\n")
228
+ return None
229
+
230
+ return real_path
231
+
232
+ async def cmd_find(self, args: list[str]) -> None:
233
+ """Find files matching criteria."""
234
+ start_virtual = self._resolve_virtual_path(args[0] if args else self.cwd)
235
+ start_real = self._to_real_path(start_virtual)
236
+
237
+ if start_virtual == "/invalid" or start_real is None or not start_real.exists():
238
+ self.stdout.write(f"find: no such file or directory: {start_virtual}\n")
239
+ return
240
+
241
+ name_filter = None
242
+ if len(args) >= 3 and args[1] == "-name":
243
+ name_filter = args[2]
244
+
245
+ paths = (
246
+ [start_real]
247
+ if start_real.is_file()
248
+ else [start_real, *sorted(start_real.rglob("*"))]
249
+ )
250
+
251
+ for path in paths:
252
+ if name_filter and not self._matches_name(path.name, name_filter):
253
+ continue
254
+ self.stdout.write(f"{self._to_virtual_path(path)}\n")
255
+
256
+ def _matches_name(self, name: str, pattern: str) -> bool:
257
+ """Check if filename matches glob pattern."""
258
+ regex = "^" + re.escape(pattern).replace(r"\*", ".*") + "$"
259
+ return re.match(regex, name) is not None
260
+
261
+ async def cmd_grep(self, args: list[str]) -> None:
262
+ """Search file contents."""
263
+ recursive = False
264
+ filtered: list[str] = []
265
+
266
+ for arg in args:
267
+ if arg == "-R":
268
+ recursive = True
269
+ else:
270
+ filtered.append(arg)
271
+
272
+ if len(filtered) < 2:
273
+ self.stdout.write("grep: usage: grep [-R] <pattern> <path>\n")
274
+ return
275
+
276
+ pattern = filtered[0]
277
+ start_virtual = self._resolve_virtual_path(filtered[1])
278
+ start_real = self._to_real_path(start_virtual)
279
+
280
+ if start_virtual == "/invalid" or start_real is None or not start_real.exists():
281
+ self.stdout.write(f"grep: no such file or directory: {start_virtual}\n")
282
+ return
283
+
284
+ if start_real.is_dir() and not recursive:
285
+ self.stdout.write("grep: path is a directory, use -R\n")
286
+ return
287
+
288
+ targets = (
289
+ [start_real]
290
+ if start_real.is_file()
291
+ else sorted(p for p in start_real.rglob("*") if p.is_file())
292
+ )
293
+
294
+ found = False
295
+ for target in targets:
296
+ try:
297
+ lines = target.read_text(encoding="utf-8").splitlines()
298
+ except UnicodeDecodeError:
299
+ continue
300
+
301
+ for index, line in enumerate(lines, start=1):
302
+ if pattern.lower() in line.lower():
303
+ found = True
304
+ self.stdout.write(f"{self._to_virtual_path(target)}:{index}:{line}\n")
305
+
306
+ if not found:
307
+ self.stdout.write("grep: no matches\n")