sh-agent 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.
- sh_agent/__init__.py +19 -0
- sh_agent/__main__.py +141 -0
- sh_agent/app.py +97 -0
- sh_agent/auth.py +147 -0
- sh_agent/config.py +60 -0
- sh_agent/env.py +35 -0
- sh_agent/onboarding.py +185 -0
- sh_agent/palette.py +207 -0
- sh_agent/protocol.py +77 -0
- sh_agent/pty_session.py +355 -0
- sh_agent/registry.py +350 -0
- sh_agent/routes/__init__.py +0 -0
- sh_agent/routes/commands.py +97 -0
- sh_agent/routes/deps.py +62 -0
- sh_agent/routes/health.py +63 -0
- sh_agent/routes/session.py +314 -0
- sh_agent/tmux.py +159 -0
- sh_agent-0.1.0.dist-info/METADATA +367 -0
- sh_agent-0.1.0.dist-info/RECORD +22 -0
- sh_agent-0.1.0.dist-info/WHEEL +4 -0
- sh_agent-0.1.0.dist-info/entry_points.txt +2 -0
- sh_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
sh_agent/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .auth import Authenticator, AuthError, Identity
|
|
2
|
+
from .config import Config
|
|
3
|
+
from .protocol import PROTOCOL_VERSION, ClientMessage, ServerMessage
|
|
4
|
+
from .pty_session import PtySession, RingBuffer
|
|
5
|
+
from .registry import AuditLog, SessionRegistry
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"PROTOCOL_VERSION",
|
|
9
|
+
"AuditLog",
|
|
10
|
+
"AuthError",
|
|
11
|
+
"Authenticator",
|
|
12
|
+
"ClientMessage",
|
|
13
|
+
"Config",
|
|
14
|
+
"Identity",
|
|
15
|
+
"PtySession",
|
|
16
|
+
"RingBuffer",
|
|
17
|
+
"ServerMessage",
|
|
18
|
+
"SessionRegistry",
|
|
19
|
+
]
|
sh_agent/__main__.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from . import onboarding
|
|
10
|
+
from .app import create_app
|
|
11
|
+
from .auth import AuthConfig
|
|
12
|
+
from .config import Config, tailnet_address
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _pairing_banner(
|
|
16
|
+
bind: str,
|
|
17
|
+
port: int,
|
|
18
|
+
token: str,
|
|
19
|
+
write_token: str | None,
|
|
20
|
+
generated: bool = False,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Print the address, a scannable code, and a copy-pasteable link.
|
|
23
|
+
|
|
24
|
+
Every line is flushed as it is produced. The agent runs forever afterwards, so
|
|
25
|
+
anything left in a buffer would never reach the user -- and the QR is the one
|
|
26
|
+
piece of output somebody cannot wait for.
|
|
27
|
+
"""
|
|
28
|
+
tailnet = tailnet_address()
|
|
29
|
+
print(f"sh-agent listening on {bind}:{port}", flush=True)
|
|
30
|
+
if tailnet and bind == tailnet:
|
|
31
|
+
print(f"reachable from tailnet at {bind}:{port}", flush=True)
|
|
32
|
+
|
|
33
|
+
host, source = onboarding.resolve_pairing_host(bind, tailnet)
|
|
34
|
+
if not host:
|
|
35
|
+
print(
|
|
36
|
+
f"not printing a pairing code: listening on {bind}, which a phone cannot reach.\n"
|
|
37
|
+
"start with SH_HOST=0.0.0.0 (shared LAN) or run Tailscale for tailnet access.",
|
|
38
|
+
flush=True,
|
|
39
|
+
)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
uri = onboarding.pairing_uri(host, port, token, write_token)
|
|
43
|
+
if generated:
|
|
44
|
+
print(
|
|
45
|
+
f"generated a new token, stored in {onboarding.DEFAULT_CONFIG_DIR / 'agent.env'}",
|
|
46
|
+
flush=True,
|
|
47
|
+
)
|
|
48
|
+
if source == "lan":
|
|
49
|
+
print(
|
|
50
|
+
f"pairing over the local network ({host}); run Tailscale to pair from anywhere",
|
|
51
|
+
flush=True,
|
|
52
|
+
)
|
|
53
|
+
print("\nPair your phone by scanning this, or open the link:\n", flush=True)
|
|
54
|
+
print(onboarding.render_qr(uri), flush=True)
|
|
55
|
+
print(f"\n{uri}\n", flush=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
parser = argparse.ArgumentParser(prog="sh-agent")
|
|
60
|
+
parser.add_argument("--host", default=None)
|
|
61
|
+
parser.add_argument("--port", type=int, default=None)
|
|
62
|
+
parser.add_argument("--allow-lan", action="store_true")
|
|
63
|
+
parser.add_argument("--shell", default=None)
|
|
64
|
+
parser.add_argument("--cwd", default=None)
|
|
65
|
+
parser.add_argument("--palette", default=None)
|
|
66
|
+
parser.add_argument("--audit", default=None)
|
|
67
|
+
parser.add_argument("--generate-token", action="store_true")
|
|
68
|
+
parser.add_argument("--print-bind", action="store_true")
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--pairing",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="print the pairing code for the current token and exit",
|
|
73
|
+
)
|
|
74
|
+
args = parser.parse_args()
|
|
75
|
+
|
|
76
|
+
if args.generate_token:
|
|
77
|
+
print(secrets.token_urlsafe(32))
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
config = Config()
|
|
81
|
+
if args.host:
|
|
82
|
+
config.host = args.host
|
|
83
|
+
if args.port:
|
|
84
|
+
config.port = args.port
|
|
85
|
+
if args.allow_lan:
|
|
86
|
+
config.allow_lan = True
|
|
87
|
+
if args.shell:
|
|
88
|
+
config.shell = args.shell
|
|
89
|
+
if args.cwd:
|
|
90
|
+
config.cwd = args.cwd
|
|
91
|
+
if args.palette:
|
|
92
|
+
config.palette_path = args.palette
|
|
93
|
+
if args.audit:
|
|
94
|
+
config.audit_path = args.audit
|
|
95
|
+
config.auth = Config().auth
|
|
96
|
+
|
|
97
|
+
if args.print_bind:
|
|
98
|
+
print(config.public_bind)
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
# Resolve credentials before building the auth config, so a token generated on
|
|
102
|
+
# this run is the one the agent actually serves. Environment wins, then a
|
|
103
|
+
# previously stored token, then a freshly generated one that gets persisted --
|
|
104
|
+
# a generated token that is not stored leaves the phone's saved copy invalid
|
|
105
|
+
# the moment this terminal closes, with no way to recover it.
|
|
106
|
+
credentials = onboarding.load_or_create(
|
|
107
|
+
env_token=os.environ.get("SH_TOKEN"),
|
|
108
|
+
env_write_token=os.environ.get("SH_WRITE_TOKEN"),
|
|
109
|
+
)
|
|
110
|
+
config.auth = AuthConfig(
|
|
111
|
+
token=credentials.token,
|
|
112
|
+
write_token=credentials.write_token,
|
|
113
|
+
tailscale_require=config.auth.tailscale_require,
|
|
114
|
+
tailscale_allow=config.auth.tailscale_allow,
|
|
115
|
+
tailscale_socket=config.auth.tailscale_socket,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
bind = config.public_bind
|
|
119
|
+
|
|
120
|
+
# --pairing reprints the code without starting a server, so a second phone can
|
|
121
|
+
# be added, or a lost one replaced, without touching the host.
|
|
122
|
+
if args.pairing:
|
|
123
|
+
_pairing_banner(bind, config.port, credentials.token, credentials.write_token, credentials.generated)
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
if config.allow_lan:
|
|
127
|
+
print("WARNING: LAN binding enabled; the daemon is reachable outside the tailnet", flush=True)
|
|
128
|
+
|
|
129
|
+
_pairing_banner(bind, config.port, credentials.token, credentials.write_token, credentials.generated)
|
|
130
|
+
|
|
131
|
+
if config.auth.write_token:
|
|
132
|
+
print("palette write access: ENABLED", flush=True)
|
|
133
|
+
else:
|
|
134
|
+
print("palette write access: disabled (set SH_WRITE_TOKEN to enable)", flush=True)
|
|
135
|
+
|
|
136
|
+
uvicorn.run(create_app(config), host=bind, port=config.port, log_level="info", access_log=False)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
raise SystemExit(main())
|
sh_agent/app.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from .auth import Authenticator, AuthError
|
|
11
|
+
from .config import Config
|
|
12
|
+
from .palette import PaletteStore
|
|
13
|
+
from .registry import AuditLog, SessionRegistry
|
|
14
|
+
from .routes import commands, health, session
|
|
15
|
+
from .tmux import SlugNamer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@asynccontextmanager
|
|
19
|
+
async def lifespan(app: FastAPI):
|
|
20
|
+
config: Config = app.state.config
|
|
21
|
+
audit: AuditLog = app.state.audit
|
|
22
|
+
loop = asyncio.get_running_loop()
|
|
23
|
+
|
|
24
|
+
registry = SessionRegistry(
|
|
25
|
+
loop=loop,
|
|
26
|
+
shell=config.shell,
|
|
27
|
+
cwd=config.cwd,
|
|
28
|
+
idle_ttl=config.idle_ttl,
|
|
29
|
+
max_sessions=config.max_sessions,
|
|
30
|
+
detach_grace=config.detach_grace,
|
|
31
|
+
reap_interval=config.reap_interval,
|
|
32
|
+
ring_capacity=config.ring_capacity,
|
|
33
|
+
namer=SlugNamer(config.tmux_secret),
|
|
34
|
+
tmux_max_sessions=config.tmux_max_sessions,
|
|
35
|
+
tmux_idle_ttl=config.tmux_idle_ttl,
|
|
36
|
+
tmux_enabled=config.tmux_enabled,
|
|
37
|
+
)
|
|
38
|
+
app.state.registry = registry
|
|
39
|
+
registry.start_reaper()
|
|
40
|
+
|
|
41
|
+
audit.record(
|
|
42
|
+
"agent.start",
|
|
43
|
+
bind=f"{config.public_bind}:{config.port}",
|
|
44
|
+
allow_lan=config.allow_lan,
|
|
45
|
+
write_enabled=bool(config.auth.write_token),
|
|
46
|
+
tailscale_required=config.auth.tailscale_require,
|
|
47
|
+
)
|
|
48
|
+
if audit.error:
|
|
49
|
+
print(
|
|
50
|
+
f"WARNING: audit log disabled -- {audit.error}\n"
|
|
51
|
+
f" set SH_AUDIT to a writable path to record auth and session events",
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
audit.record("agent.stop")
|
|
58
|
+
await registry.shutdown()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_app(config: Config | None = None) -> FastAPI:
|
|
62
|
+
resolved = config or Config()
|
|
63
|
+
app = FastAPI(
|
|
64
|
+
title="sh agent",
|
|
65
|
+
version="0.1.0",
|
|
66
|
+
lifespan=lifespan,
|
|
67
|
+
docs_url=None,
|
|
68
|
+
redoc_url=None,
|
|
69
|
+
)
|
|
70
|
+
app.state.config = resolved
|
|
71
|
+
app.state.audit = AuditLog(resolved.audit_path)
|
|
72
|
+
app.state.palette = PaletteStore(resolved.palette_path)
|
|
73
|
+
app.state.authenticator = Authenticator(resolved.auth)
|
|
74
|
+
|
|
75
|
+
@app.exception_handler(AuthError)
|
|
76
|
+
async def auth_error_handler(request: Request, exc: AuthError) -> JSONResponse:
|
|
77
|
+
resolved_audit: AuditLog = request.app.state.audit
|
|
78
|
+
resolved_audit.record(
|
|
79
|
+
"auth.rejected",
|
|
80
|
+
peer_ip=request.client.host if request.client else None,
|
|
81
|
+
reason=str(exc),
|
|
82
|
+
status=exc.status_code,
|
|
83
|
+
)
|
|
84
|
+
headers = {"WWW-Authenticate": "Bearer"} if exc.status_code == 401 else None
|
|
85
|
+
return JSONResponse(
|
|
86
|
+
status_code=exc.status_code,
|
|
87
|
+
content={"detail": str(exc)},
|
|
88
|
+
headers=headers,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
app.include_router(health.router)
|
|
92
|
+
app.include_router(session.router)
|
|
93
|
+
app.include_router(commands.router)
|
|
94
|
+
return app
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
app = create_app()
|
sh_agent/auth.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import http.client
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from .env import bool_env as _bool_env
|
|
11
|
+
from .env import list_env as _list_env
|
|
12
|
+
|
|
13
|
+
TAILSCALE_SOCKET = "/var/run/tailscale/tailscaled.sock"
|
|
14
|
+
LOCALAPI_PREFIX = "/localapi/v0"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Identity:
|
|
19
|
+
peer_ip: str | None = None
|
|
20
|
+
login: str | None = None
|
|
21
|
+
node: str | None = None
|
|
22
|
+
verified_by: str = "token"
|
|
23
|
+
|
|
24
|
+
def as_dict(self) -> dict[str, object]:
|
|
25
|
+
return {
|
|
26
|
+
"peer_ip": self.peer_ip,
|
|
27
|
+
"login": self.login,
|
|
28
|
+
"node": self.node,
|
|
29
|
+
"verified_by": self.verified_by,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthError(Exception):
|
|
34
|
+
def __init__(self, message: str, status_code: int = 401) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class AuthConfig:
|
|
41
|
+
token: str | None = None
|
|
42
|
+
write_token: str | None = None
|
|
43
|
+
tailscale_require: bool = field(default_factory=lambda: _bool_env("SH_TAILSCALE_REQUIRE"))
|
|
44
|
+
tailscale_allow: list[str] = field(default_factory=lambda: _list_env("SH_TAILSCALE_ALLOW"))
|
|
45
|
+
tailscale_socket: str = field(
|
|
46
|
+
default_factory=lambda: os.environ.get("SH_TAILSCALE_SOCKET", TAILSCALE_SOCKET)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_env(cls) -> AuthConfig:
|
|
51
|
+
return cls(
|
|
52
|
+
token=os.environ.get("SH_TOKEN") or None,
|
|
53
|
+
write_token=os.environ.get("SH_WRITE_TOKEN") or None,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _UnixHTTPConnection(http.client.HTTPConnection):
|
|
58
|
+
def __init__(self, socket_path: str, timeout: float = 2.0) -> None:
|
|
59
|
+
super().__init__("localhost", timeout=timeout)
|
|
60
|
+
self._socket_path = socket_path
|
|
61
|
+
|
|
62
|
+
def connect(self) -> None:
|
|
63
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
64
|
+
sock.settimeout(self.timeout)
|
|
65
|
+
sock.connect(self._socket_path)
|
|
66
|
+
self.sock = sock
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TailscaleWhois:
|
|
70
|
+
def __init__(self, socket_path: str = TAILSCALE_SOCKET) -> None:
|
|
71
|
+
self._socket_path = socket_path
|
|
72
|
+
|
|
73
|
+
def available(self) -> bool:
|
|
74
|
+
return os.path.exists(self._socket_path)
|
|
75
|
+
|
|
76
|
+
def lookup(self, addr: str) -> dict[str, object]:
|
|
77
|
+
conn = _UnixHTTPConnection(self._socket_path)
|
|
78
|
+
try:
|
|
79
|
+
conn.request("GET", f"{LOCALAPI_PREFIX}/whois?addr={addr}")
|
|
80
|
+
response = conn.getresponse()
|
|
81
|
+
body = response.read()
|
|
82
|
+
finally:
|
|
83
|
+
conn.close()
|
|
84
|
+
if response.status != 200:
|
|
85
|
+
raise AuthError(f"whois failed with status {response.status}")
|
|
86
|
+
return json.loads(body)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Authenticator:
|
|
90
|
+
def __init__(self, config: AuthConfig, whois: TailscaleWhois | None = None) -> None:
|
|
91
|
+
self.config = config
|
|
92
|
+
self.whois = whois or TailscaleWhois(config.tailscale_socket)
|
|
93
|
+
|
|
94
|
+
def _constant_time_equals(self, provided: str | None, expected: str | None) -> bool:
|
|
95
|
+
if not expected or provided is None:
|
|
96
|
+
return False
|
|
97
|
+
return hmac.compare_digest(provided, expected)
|
|
98
|
+
|
|
99
|
+
def extract_bearer(self, authorization: str | None) -> str | None:
|
|
100
|
+
if not authorization:
|
|
101
|
+
return None
|
|
102
|
+
scheme, _, value = authorization.partition(" ")
|
|
103
|
+
if scheme.lower() != "bearer" or not value.strip():
|
|
104
|
+
return None
|
|
105
|
+
return value.strip()
|
|
106
|
+
|
|
107
|
+
def identify(self, peer_ip: str | None) -> Identity:
|
|
108
|
+
identity = Identity(peer_ip=peer_ip)
|
|
109
|
+
if peer_ip and self.whois.available():
|
|
110
|
+
try:
|
|
111
|
+
data = self.whois.lookup(f"{peer_ip}:0")
|
|
112
|
+
except Exception:
|
|
113
|
+
data = None
|
|
114
|
+
if isinstance(data, dict):
|
|
115
|
+
logins = (data.get("UserProfile") or {}).get("LoginName") or []
|
|
116
|
+
if logins:
|
|
117
|
+
identity.login = str(logins[0])
|
|
118
|
+
identity.node = data.get("NodeName")
|
|
119
|
+
identity.verified_by = "tailscale"
|
|
120
|
+
return identity
|
|
121
|
+
|
|
122
|
+
def _enforce_tailnet(self, identity: Identity) -> Identity:
|
|
123
|
+
if self.config.tailscale_require and identity.verified_by != "tailscale":
|
|
124
|
+
raise AuthError("tailscale identity required but unavailable")
|
|
125
|
+
if self.config.tailscale_allow:
|
|
126
|
+
if identity.verified_by != "tailscale":
|
|
127
|
+
raise AuthError("peer is not on the tailnet")
|
|
128
|
+
if identity.login not in self.config.tailscale_allow:
|
|
129
|
+
raise AuthError(f"identity not allowed: {identity.login}")
|
|
130
|
+
return identity
|
|
131
|
+
|
|
132
|
+
def enforce(self, authorization: str | None, peer_ip: str | None) -> Identity:
|
|
133
|
+
if not self.config.token:
|
|
134
|
+
raise AuthError("server has no SH_TOKEN configured")
|
|
135
|
+
if not self._constant_time_equals(self.extract_bearer(authorization), self.config.token):
|
|
136
|
+
raise AuthError("invalid token")
|
|
137
|
+
return self._enforce_tailnet(self.identify(peer_ip))
|
|
138
|
+
|
|
139
|
+
def enforce_write(self, authorization: str | None, peer_ip: str | None) -> Identity:
|
|
140
|
+
provided = self.extract_bearer(authorization)
|
|
141
|
+
if not self.config.write_token:
|
|
142
|
+
raise AuthError("write access disabled: set SH_WRITE_TOKEN to enable", status_code=403)
|
|
143
|
+
if self._constant_time_equals(provided, self.config.write_token):
|
|
144
|
+
return self._enforce_tailnet(self.identify(peer_ip))
|
|
145
|
+
if self.config.token and self._constant_time_equals(provided, self.config.token):
|
|
146
|
+
raise AuthError("write token required", status_code=403)
|
|
147
|
+
raise AuthError("invalid token")
|
sh_agent/config.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .auth import AuthConfig
|
|
8
|
+
from .env import bool_env as _bool_env
|
|
9
|
+
from .env import float_env as _float_env
|
|
10
|
+
from .env import int_env as _int_env
|
|
11
|
+
|
|
12
|
+
DEFAULT_PORT = 8765
|
|
13
|
+
DEFAULT_PALETTE_PATH = os.path.expanduser("~/.config/sh/palette.json")
|
|
14
|
+
DEFAULT_AUDIT_PATH = os.path.expanduser("~/.local/state/sh/audit.log")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def tailnet_address() -> str | None:
|
|
18
|
+
try:
|
|
19
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
|
|
20
|
+
probe.settimeout(0.2)
|
|
21
|
+
probe.connect(("100.100.100.100", 1))
|
|
22
|
+
address = probe.getsockname()[0]
|
|
23
|
+
return address if address.startswith("100.") else None
|
|
24
|
+
except OSError:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Config:
|
|
30
|
+
host: str = field(default_factory=lambda: os.environ.get("SH_HOST", "127.0.0.1"))
|
|
31
|
+
port: int = field(default_factory=lambda: _int_env("SH_PORT", DEFAULT_PORT))
|
|
32
|
+
allow_lan: bool = field(
|
|
33
|
+
default_factory=lambda: os.environ.get("SH_ALLOW_LAN", "").lower() in {"1", "true", "yes"}
|
|
34
|
+
)
|
|
35
|
+
shell: str | None = field(default_factory=lambda: os.environ.get("SH_SHELL"))
|
|
36
|
+
cwd: str | None = field(default_factory=lambda: os.environ.get("SH_CWD") or None)
|
|
37
|
+
palette_path: str = field(default_factory=lambda: os.environ.get("SH_PALETTE", DEFAULT_PALETTE_PATH))
|
|
38
|
+
audit_path: str | None = field(default_factory=lambda: os.environ.get("SH_AUDIT", DEFAULT_AUDIT_PATH))
|
|
39
|
+
idle_ttl: float = field(default_factory=lambda: _float_env("SH_IDLE_TTL", 30 * 60.0))
|
|
40
|
+
detach_grace: float = field(default_factory=lambda: _float_env("SH_DETACH_GRACE", 120.0))
|
|
41
|
+
reap_interval: float = field(default_factory=lambda: _float_env("SH_REAP_INTERVAL", 15.0))
|
|
42
|
+
max_sessions: int = field(default_factory=lambda: _int_env("SH_MAX_SESSIONS", 8))
|
|
43
|
+
ring_capacity: int = field(default_factory=lambda: _int_env("SH_RING_CAPACITY", 256 * 1024))
|
|
44
|
+
auth: AuthConfig = field(default_factory=AuthConfig.from_env)
|
|
45
|
+
tmux_enabled: bool = field(default_factory=lambda: _bool_env("SH_TMUX_ENABLED", True))
|
|
46
|
+
tmux_max_sessions: int = field(default_factory=lambda: _int_env("SH_TMUX_MAX_SESSIONS", 12))
|
|
47
|
+
tmux_idle_ttl: float = field(default_factory=lambda: _float_env("SH_TMUX_IDLE_TTL", 3600.0))
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def tmux_secret(self) -> str:
|
|
51
|
+
configured = os.environ.get("SH_TMUX_SECRET")
|
|
52
|
+
if configured:
|
|
53
|
+
return configured
|
|
54
|
+
return self.auth.token or "sh-default"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def public_bind(self) -> str:
|
|
58
|
+
if self.allow_lan:
|
|
59
|
+
return self.host
|
|
60
|
+
return tailnet_address() or self.host
|
sh_agent/env.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def int_env(name: str, default: int) -> int:
|
|
7
|
+
raw = os.environ.get(name)
|
|
8
|
+
if raw is None or not raw.strip():
|
|
9
|
+
return default
|
|
10
|
+
try:
|
|
11
|
+
return int(raw)
|
|
12
|
+
except ValueError:
|
|
13
|
+
return default
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def float_env(name: str, default: float) -> float:
|
|
17
|
+
raw = os.environ.get(name)
|
|
18
|
+
if raw is None or not raw.strip():
|
|
19
|
+
return default
|
|
20
|
+
try:
|
|
21
|
+
return float(raw)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return default
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def bool_env(name: str, default: bool = False) -> bool:
|
|
27
|
+
raw = os.environ.get(name)
|
|
28
|
+
if raw is None:
|
|
29
|
+
return default
|
|
30
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_env(name: str) -> list[str]:
|
|
34
|
+
raw = os.environ.get(name, "")
|
|
35
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
sh_agent/onboarding.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Persisted credentials and phone pairing.
|
|
2
|
+
|
|
3
|
+
The token used to be generated, printed and thrown away, which meant closing the
|
|
4
|
+
terminal that started the agent invalidated whatever the phone had already saved
|
|
5
|
+
with no way to recover it. It is now written to a file the agent reads on the
|
|
6
|
+
next start, and the phone pairs by scanning a QR code rather than by someone
|
|
7
|
+
reading an IP off one line and typing it into a phone keyboard.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG_DIR = Path(os.environ.get("SH_CONFIG_DIR") or Path.home() / ".config" / "sh")
|
|
20
|
+
SCHEME = "sh"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Credentials:
|
|
25
|
+
token: str
|
|
26
|
+
write_token: str | None = None
|
|
27
|
+
generated: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_token() -> str:
|
|
31
|
+
return secrets.token_urlsafe(32)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
35
|
+
"""Read KEY=VALUE lines, ignoring anything unparseable.
|
|
36
|
+
|
|
37
|
+
This file is written by us, but a user editing it by hand should not get a
|
|
38
|
+
stack trace because of a stray comment or a blank line.
|
|
39
|
+
"""
|
|
40
|
+
values: dict[str, str] = {}
|
|
41
|
+
try:
|
|
42
|
+
text = path.read_text(encoding="utf-8")
|
|
43
|
+
except OSError:
|
|
44
|
+
return values
|
|
45
|
+
for raw in text.splitlines():
|
|
46
|
+
line = raw.strip()
|
|
47
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
48
|
+
continue
|
|
49
|
+
key, _, value = line.partition("=")
|
|
50
|
+
key = key.strip()
|
|
51
|
+
if not key:
|
|
52
|
+
continue
|
|
53
|
+
value = value.strip().strip("'\"")
|
|
54
|
+
if value:
|
|
55
|
+
values[key] = value
|
|
56
|
+
return values
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_env_file(path: Path, values: dict[str, str]) -> None:
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
# Written literally, not percent-encoded: systemd parses EnvironmentFile as
|
|
62
|
+
# KEY=value, so encoding here would corrupt the token it hands the agent.
|
|
63
|
+
# token_urlsafe only ever emits [A-Za-z0-9_-], which needs no quoting.
|
|
64
|
+
lines = []
|
|
65
|
+
for key, value in values.items():
|
|
66
|
+
if "\n" in value or "\r" in value or " " in value:
|
|
67
|
+
raise ValueError(f"refusing to persist {key}: value is not safe for an env file")
|
|
68
|
+
lines.append(f"{key}={value}")
|
|
69
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
70
|
+
# The token is a shell credential; 0600 so it is not world-readable, and a
|
|
71
|
+
# directory that does not leak its contents.
|
|
72
|
+
path.chmod(0o600)
|
|
73
|
+
try:
|
|
74
|
+
path.parent.chmod(0o700)
|
|
75
|
+
except OSError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_or_create(
|
|
80
|
+
config_dir: Path | None = None,
|
|
81
|
+
env_token: str | None = None,
|
|
82
|
+
env_write_token: str | None = None,
|
|
83
|
+
) -> Credentials:
|
|
84
|
+
"""Resolve credentials from the environment, falling back to a stored file.
|
|
85
|
+
|
|
86
|
+
Precedence is environment, then a previously stored token, then a freshly
|
|
87
|
+
generated one. Only the generated token is ever written back: an environment
|
|
88
|
+
variable is a per-run override, and persisting it would let a value that
|
|
89
|
+
happened to be set for one invocation -- a test harness, a shell export --
|
|
90
|
+
silently become permanent configuration.
|
|
91
|
+
"""
|
|
92
|
+
directory = config_dir or DEFAULT_CONFIG_DIR
|
|
93
|
+
path = directory / "agent.env"
|
|
94
|
+
|
|
95
|
+
stored = _parse_env_file(path) if path.exists() else {}
|
|
96
|
+
|
|
97
|
+
token = env_token or stored.get("SH_TOKEN") or ""
|
|
98
|
+
write_token = env_write_token or stored.get("SH_WRITE_TOKEN") or None
|
|
99
|
+
generated = False
|
|
100
|
+
|
|
101
|
+
if not token:
|
|
102
|
+
token = generate_token()
|
|
103
|
+
generated = True
|
|
104
|
+
stored["SH_TOKEN"] = token
|
|
105
|
+
try:
|
|
106
|
+
_write_env_file(path, stored)
|
|
107
|
+
except OSError:
|
|
108
|
+
# A read-only home should not stop the agent from running; the token
|
|
109
|
+
# is printed in that case so the user can still pair.
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
return Credentials(token=token, write_token=write_token, generated=generated)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def local_address() -> str | None:
|
|
116
|
+
"""The address this machine reaches the outside world from.
|
|
117
|
+
|
|
118
|
+
Used to turn a wildcard bind into something a phone can actually dial. The
|
|
119
|
+
UDP connect sends no packets; it just asks the routing table which local
|
|
120
|
+
address would be used, which avoids enumerating interfaces.
|
|
121
|
+
"""
|
|
122
|
+
import socket
|
|
123
|
+
|
|
124
|
+
for probe in ("100.100.100.100", "8.8.8.8"):
|
|
125
|
+
try:
|
|
126
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
127
|
+
sock.settimeout(0.2)
|
|
128
|
+
sock.connect((probe, 1))
|
|
129
|
+
return str(sock.getsockname()[0])
|
|
130
|
+
except OSError:
|
|
131
|
+
continue
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def resolve_pairing_host(bind: str, tailnet: str | None = None) -> tuple[str | None, str]:
|
|
136
|
+
"""Pick an address to put in the pairing code, and say why.
|
|
137
|
+
|
|
138
|
+
A wildcard bind is what the agent listens on, not what a phone dials, so it
|
|
139
|
+
never appears in a pairing code. The tailnet address is preferred over the
|
|
140
|
+
LAN one because it is the only one that also works off the home network.
|
|
141
|
+
"""
|
|
142
|
+
if tailnet and bind != "127.0.0.1":
|
|
143
|
+
return tailnet, "tailnet"
|
|
144
|
+
if bind not in ("0.0.0.0", "::", ""):
|
|
145
|
+
if bind in ("127.0.0.1", "::1", "localhost"):
|
|
146
|
+
return None, "loopback"
|
|
147
|
+
return bind, "configured"
|
|
148
|
+
address = local_address()
|
|
149
|
+
if address:
|
|
150
|
+
return address, "lan"
|
|
151
|
+
return None, "unknown"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def pairing_uri(host: str, port: int, token: str, write_token: str | None = None) -> str:
|
|
155
|
+
"""A link the phone can open or scan.
|
|
156
|
+
|
|
157
|
+
Percent-encoded rather than JSON so the QR payload stays a plain URI that a
|
|
158
|
+
camera app, a browser and the app's own deep-link handler all understand.
|
|
159
|
+
"""
|
|
160
|
+
parts = [f"host={quote(host, safe='')}", f"port={port}", f"token={quote(token, safe='')}"]
|
|
161
|
+
if write_token:
|
|
162
|
+
parts.append(f"wt={quote(write_token, safe='')}")
|
|
163
|
+
return f"{SCHEME}://connect?{'&'.join(parts)}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def render_qr(uri: str, border: int = 2) -> str:
|
|
167
|
+
"""A scannable QR in the terminal.
|
|
168
|
+
|
|
169
|
+
segno renders to text with no image dependency, which matters for a package
|
|
170
|
+
whose whole job is to run on whatever machine someone has. It writes to the
|
|
171
|
+
stream it is given and returns None, so it is handed a buffer rather than
|
|
172
|
+
being allowed to print behind the caller's back.
|
|
173
|
+
|
|
174
|
+
compact=True draws each module as a half-block glyph instead of a coloured
|
|
175
|
+
space. The default output depends on the terminal honouring ANSI colour and
|
|
176
|
+
turns into unreadable escape codes the moment it is piped to a file, run
|
|
177
|
+
under tmux, or read by a camera that cannot resolve the colour.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
import segno
|
|
181
|
+
except ImportError: # pragma: no cover - segno is a declared dependency
|
|
182
|
+
return ""
|
|
183
|
+
buffer = io.StringIO()
|
|
184
|
+
segno.make(uri, error="m").terminal(out=buffer, border=border, compact=True)
|
|
185
|
+
return buffer.getvalue()
|