sitecard 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: sitecard
3
+ Version: 0.1.0
4
+ Summary: Issue temporary SSH access cards for development workspaces
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: asyncssh==2.24.0
8
+ Requires-Dist: cryptography>=39
9
+ Requires-Dist: websockets
10
+
11
+ # Sitecard
12
+
13
+ Issue temporary SSH access cards for development sites and workspaces.
14
+
15
+ This directory holds only the static sanitized README staged into the
16
+ generated `sitecard` distribution. The distributable package itself is
17
+ generated by `scripts/build-sitecard-dist.sh` and never checked in.
18
+
19
+ ## What it is
20
+
21
+ Sitecard opens temporary authenticated SSH access to a selected development
22
+ workspace for remote debugging and compatibility testing. The site is the
23
+ workspace host; the card is the short-lived access path.
24
+
25
+ - Distribution name: `sitecard`
26
+ - Version: `0.1.0`
27
+ - Description: `Issue temporary SSH access cards for development workspaces`
28
+ - Requires Python `>=3.10`
29
+ - Console script: `sitecard = sitecard.commands:main`
30
+
31
+ ## What it is not
32
+
33
+ The generated distribution contains no deployment helpers, local secrets,
34
+ target addresses, assessment keys, tests, or unrelated files.
35
+
36
+ ## Build
37
+
38
+ From the repository root:
39
+
40
+ ```sh
41
+ sh scripts/build-sitecard-dist.sh
42
+ ```
43
+
44
+ Artifacts land in `dist/sitecard/`. The build fails if any
45
+ case-insensitive occurrence of the internal project name remains in
46
+ staging files, archive entry names, or archive file contents.
47
+
48
+ ## Use
49
+
50
+ ```sh
51
+ sitecard -h
52
+ sitecard serve --help
53
+ sitecard serve .
54
+ ```
55
+
56
+ `serve` is a true alias of `start` with identical arguments.
@@ -0,0 +1,46 @@
1
+ # Sitecard
2
+
3
+ Issue temporary SSH access cards for development sites and workspaces.
4
+
5
+ This directory holds only the static sanitized README staged into the
6
+ generated `sitecard` distribution. The distributable package itself is
7
+ generated by `scripts/build-sitecard-dist.sh` and never checked in.
8
+
9
+ ## What it is
10
+
11
+ Sitecard opens temporary authenticated SSH access to a selected development
12
+ workspace for remote debugging and compatibility testing. The site is the
13
+ workspace host; the card is the short-lived access path.
14
+
15
+ - Distribution name: `sitecard`
16
+ - Version: `0.1.0`
17
+ - Description: `Issue temporary SSH access cards for development workspaces`
18
+ - Requires Python `>=3.10`
19
+ - Console script: `sitecard = sitecard.commands:main`
20
+
21
+ ## What it is not
22
+
23
+ The generated distribution contains no deployment helpers, local secrets,
24
+ target addresses, assessment keys, tests, or unrelated files.
25
+
26
+ ## Build
27
+
28
+ From the repository root:
29
+
30
+ ```sh
31
+ sh scripts/build-sitecard-dist.sh
32
+ ```
33
+
34
+ Artifacts land in `dist/sitecard/`. The build fails if any
35
+ case-insensitive occurrence of the internal project name remains in
36
+ staging files, archive entry names, or archive file contents.
37
+
38
+ ## Use
39
+
40
+ ```sh
41
+ sitecard -h
42
+ sitecard serve --help
43
+ sitecard serve .
44
+ ```
45
+
46
+ `serve` is a true alias of `start` with identical arguments.
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=75"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sitecard"
7
+ version = "0.1.0"
8
+ description = "Issue temporary SSH access cards for development workspaces"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "asyncssh==2.24.0",
13
+ "cryptography>=39",
14
+ "websockets",
15
+ ]
16
+
17
+ [project.scripts]
18
+ sitecard = "sitecard.commands:main"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,23 @@
1
+ """Current-account helpers for hosts without a passwd database entry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import os
7
+
8
+
9
+ def current_username(default: str = "sitecard") -> str:
10
+ """Return the current username, or a stable fallback for synthetic UIDs."""
11
+ try:
12
+ return getpass.getuser() or default
13
+ except (KeyError, OSError):
14
+ return default
15
+
16
+
17
+ def ensure_username_environment(username: str | None = None) -> str:
18
+ """Populate the account variables required by libraries such as AsyncSSH."""
19
+ resolved = username or current_username()
20
+ for name in ("USER", "LOGNAME"):
21
+ if not os.environ.get(name):
22
+ os.environ[name] = resolved
23
+ return resolved
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ """Stdio/TCP <-> WebSocket bridge for the Sitecard cloudflare transport.
3
+
4
+ Two modes pump a raw byte stream across a WebSocket relay (a Cloudflare Worker):
5
+
6
+ origin Connects out to the relay and, on the first inbound byte, dials the
7
+ local SSH server, splicing the two. Loops to serve many sessions.
8
+ client Connects out to the relay and splices it to stdin/stdout, so it can be
9
+ used as an ssh ProxyCommand.
10
+
11
+ The relay pairs one "origin" and one "client" socket per tunnel id. The role
12
+ is the final path segment of ``/relay/<session>/<role>``.
13
+
14
+ Launched by the CLI as ``python -m sitecard.bridge`` for both the origin
15
+ (remote) and client (ProxyCommand) roles; it needs the websockets package.
16
+ """
17
+
18
+ import argparse
19
+ import asyncio
20
+ import os
21
+ import sys
22
+
23
+ import websockets
24
+
25
+ CHUNK = 65536
26
+ CONNECT_KWARGS = dict(max_size=None, ping_interval=20, ping_timeout=20, close_timeout=5)
27
+
28
+
29
+ def log(message):
30
+ sys.stderr.write("sitecard-bridge: " + message + "\n")
31
+ sys.stderr.flush()
32
+
33
+
34
+ def role_url(base, role):
35
+ return base.rstrip("/") + "/" + role
36
+
37
+
38
+ def _write_all(fd, data):
39
+ view = memoryview(data)
40
+ while view:
41
+ written = os.write(fd, view)
42
+ view = view[written:]
43
+
44
+
45
+ async def _await_first_completed(*coros):
46
+ tasks = [asyncio.ensure_future(coro) for coro in coros]
47
+ done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
48
+ for task in pending:
49
+ task.cancel()
50
+ for task in pending:
51
+ try:
52
+ await task
53
+ except (asyncio.CancelledError, Exception): # noqa: BLE001 - draining only
54
+ pass
55
+ for task in done:
56
+ exc = task.exception()
57
+ if exc is not None:
58
+ raise exc
59
+
60
+
61
+ async def run_client(base):
62
+ url = role_url(base, "client")
63
+ async with websockets.connect(url, **CONNECT_KWARGS) as ws:
64
+ loop = asyncio.get_event_loop()
65
+ stdin_fd = sys.stdin.fileno()
66
+
67
+ async def stdin_to_ws():
68
+ while True:
69
+ data = await loop.run_in_executor(None, os.read, stdin_fd, CHUNK)
70
+ if not data:
71
+ break
72
+ await ws.send(data)
73
+
74
+ async def ws_to_stdout():
75
+ async for message in ws:
76
+ if isinstance(message, str):
77
+ message = message.encode()
78
+ _write_all(1, message)
79
+
80
+ await _await_first_completed(stdin_to_ws(), ws_to_stdout())
81
+
82
+
83
+ async def run_origin_once(base, host, port):
84
+ url = role_url(base, "origin")
85
+ async with websockets.connect(url, **CONNECT_KWARGS) as ws:
86
+ # Wait for the client's first byte before dialing the local server, so
87
+ # the server's SSH banner is never emitted into a peerless relay.
88
+ try:
89
+ first = await ws.recv()
90
+ except websockets.ConnectionClosed:
91
+ return
92
+ if isinstance(first, str):
93
+ first = first.encode()
94
+ reader, writer = await asyncio.open_connection(host, port)
95
+ writer.write(first)
96
+ await writer.drain()
97
+
98
+ async def tcp_to_ws():
99
+ while True:
100
+ data = await reader.read(CHUNK)
101
+ if not data:
102
+ break
103
+ await ws.send(data)
104
+
105
+ async def ws_to_tcp():
106
+ async for message in ws:
107
+ if isinstance(message, str):
108
+ message = message.encode()
109
+ writer.write(message)
110
+ await writer.drain()
111
+
112
+ try:
113
+ await _await_first_completed(tcp_to_ws(), ws_to_tcp())
114
+ finally:
115
+ writer.close()
116
+ try:
117
+ await writer.wait_closed()
118
+ except Exception: # noqa: BLE001 - best-effort teardown
119
+ pass
120
+
121
+
122
+ async def run_origin(base, host, port):
123
+ delay = 1
124
+ while True:
125
+ try:
126
+ await run_origin_once(base, host, port)
127
+ delay = 1
128
+ except asyncio.CancelledError:
129
+ raise
130
+ except Exception as exc: # noqa: BLE001 - keep the tunnel alive
131
+ log("origin session ended: " + repr(exc))
132
+ await asyncio.sleep(delay)
133
+ delay = min(delay * 2, 30)
134
+ continue
135
+ await asyncio.sleep(0.5)
136
+
137
+
138
+ def main(argv=None):
139
+ parser = argparse.ArgumentParser(description="Sitecard WebSocket bridge")
140
+ sub = parser.add_subparsers(dest="mode", required=True)
141
+
142
+ client = sub.add_parser("client", help="stdio <-> relay (ssh ProxyCommand)")
143
+ client.add_argument("url", help="base relay URL, e.g. wss://host/relay/ID")
144
+
145
+ origin = sub.add_parser("origin", help="local SSH server <-> relay")
146
+ origin.add_argument("url", help="base relay URL, e.g. wss://host/relay/ID")
147
+ origin.add_argument("--host", default="127.0.0.1")
148
+ origin.add_argument("--port", type=int, required=True)
149
+
150
+ args = parser.parse_args(argv)
151
+ try:
152
+ if args.mode == "client":
153
+ asyncio.run(run_client(args.url))
154
+ else:
155
+ asyncio.run(run_origin(args.url, args.host, args.port))
156
+ except KeyboardInterrupt: # pragma: no cover
157
+ return 130
158
+ return 0
159
+
160
+
161
+ if __name__ == "__main__":
162
+ raise SystemExit(main())
@@ -0,0 +1,116 @@
1
+ """The ssh-config block for Sitecard.
2
+
3
+ ``ssh-config`` prints a ``Host`` block so plain ``ssh USER@HOST`` works on the
4
+ connecting machine. With no HOST it prints the default ``Host *.srv.us`` block
5
+ for the srv.us transport; with a HOST it prints a block for that host using the
6
+ currently configured transport's ProxyCommand. ``--write`` appends it to
7
+ ~/.ssh/config (never prepends, which would drag any leading global keywords
8
+ under the new Host block), is idempotent, chmods the file to 0600, and then runs
9
+ ``ssh -G`` to confirm the block actually took effect.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ SRVUS_PROXY_COMMAND = (
21
+ "openssl s_client -quiet -no_ign_eof -verify_return_error "
22
+ "-verify_hostname %h -connect %h:443 -servername %h 2>/dev/null"
23
+ )
24
+ DEFAULT_HOST = "*.srv.us"
25
+
26
+
27
+ def block(host: str, proxy_command: str) -> str:
28
+ """Build the ssh-config block for a host and ProxyCommand."""
29
+ return (
30
+ f"Host {host}\n"
31
+ f" ProxyCommand {proxy_command}\n"
32
+ " StrictHostKeyChecking no\n"
33
+ " UserKnownHostsFile /dev/null\n"
34
+ " LogLevel ERROR"
35
+ )
36
+
37
+
38
+ def config_path() -> Path:
39
+ return Path(os.path.expanduser("~/.ssh/config"))
40
+
41
+
42
+ def print_block(
43
+ host: str = DEFAULT_HOST, proxy_command: str = SRVUS_PROXY_COMMAND
44
+ ) -> None:
45
+ print(block(host, proxy_command))
46
+
47
+
48
+ def write(
49
+ host: str = DEFAULT_HOST, proxy_command: str = SRVUS_PROXY_COMMAND
50
+ ) -> int:
51
+ config = config_path()
52
+ ssh_dir = config.parent
53
+ try:
54
+ ssh_dir.mkdir(parents=True, exist_ok=True)
55
+ ssh_dir.chmod(0o700)
56
+ except OSError:
57
+ _error(f"Unable to prepare {ssh_dir}")
58
+ return 1
59
+
60
+ try:
61
+ present = config.is_file() and any(
62
+ line.strip().startswith("Host " + host)
63
+ for line in config.read_text().splitlines()
64
+ )
65
+ except OSError:
66
+ present = False
67
+ if present:
68
+ print(f"Already present in {config}")
69
+ else:
70
+ # Appending never changes the meaning of existing lines, unlike
71
+ # prepending, which would pull any leading global keywords under this
72
+ # Host block.
73
+ try:
74
+ with open(config, "a") as handle:
75
+ if config.exists() and config.stat().st_size > 0:
76
+ handle.write("\n")
77
+ handle.write(block(host, proxy_command) + "\n")
78
+ config.chmod(0o600)
79
+ except OSError as error:
80
+ _error(f"Unable to write {config}: {error}")
81
+ return 1
82
+ print(f"Added the {host} block to {config}")
83
+
84
+ # ssh takes the first value it finds for each keyword, so an earlier
85
+ # matching block wins. Ask ssh what it will actually do rather than assuming.
86
+ ssh = shutil.which("ssh")
87
+ if ssh:
88
+ probe = host.replace("*", "example")
89
+ try:
90
+ resolved = subprocess.run(
91
+ [ssh, "-G", probe],
92
+ capture_output=True,
93
+ text=True,
94
+ timeout=15,
95
+ ).stdout
96
+ except (OSError, subprocess.SubprocessError):
97
+ return 0
98
+ proxycommand = next(
99
+ (
100
+ line.split(" ", 1)[1].strip()
101
+ for line in resolved.splitlines()
102
+ if line.lower().startswith("proxycommand ")
103
+ ),
104
+ "",
105
+ )
106
+ marker = proxy_command.split()[0]
107
+ if marker not in proxycommand:
108
+ _error(
109
+ f"Warning: an earlier block in {config} overrides it; move the "
110
+ f'{host} block above any "Host *" block.'
111
+ )
112
+ return 0
113
+
114
+
115
+ def _error(message: str) -> None:
116
+ print(message, file=sys.stderr)