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.
- sitecard-0.1.0/PKG-INFO +56 -0
- sitecard-0.1.0/README.md +46 -0
- sitecard-0.1.0/pyproject.toml +21 -0
- sitecard-0.1.0/setup.cfg +4 -0
- sitecard-0.1.0/src/sitecard/__init__.py +1 -0
- sitecard-0.1.0/src/sitecard/account.py +23 -0
- sitecard-0.1.0/src/sitecard/bridge.py +162 -0
- sitecard-0.1.0/src/sitecard/client_config.py +116 -0
- sitecard-0.1.0/src/sitecard/commands.py +340 -0
- sitecard-0.1.0/src/sitecard/gateway.py +967 -0
- sitecard-0.1.0/src/sitecard/ingress.py +313 -0
- sitecard-0.1.0/src/sitecard/runtime.py +259 -0
- sitecard-0.1.0/src/sitecard/session.py +1143 -0
- sitecard-0.1.0/src/sitecard/supervisor.py +70 -0
- sitecard-0.1.0/src/sitecard/transports.py +488 -0
- sitecard-0.1.0/src/sitecard/wsroute.py +483 -0
- sitecard-0.1.0/src/sitecard.egg-info/PKG-INFO +56 -0
- sitecard-0.1.0/src/sitecard.egg-info/SOURCES.txt +20 -0
- sitecard-0.1.0/src/sitecard.egg-info/dependency_links.txt +1 -0
- sitecard-0.1.0/src/sitecard.egg-info/entry_points.txt +2 -0
- sitecard-0.1.0/src/sitecard.egg-info/requires.txt +3 -0
- sitecard-0.1.0/src/sitecard.egg-info/top_level.txt +1 -0
sitecard-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
sitecard-0.1.0/README.md
ADDED
|
@@ -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"]
|
sitecard-0.1.0/setup.cfg
ADDED
|
@@ -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)
|