pycodex-ws 0.4.1__tar.gz → 0.4.2__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.
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/PKG-INFO +2 -2
- pycodex_ws-0.4.2/pycodex_router/browser.py +107 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/client.py +9 -4
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/index.html +151 -28
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/protocol.py +27 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/server.py +200 -25
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pyproject.toml +2 -2
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/app.py +4 -1
- pycodex_ws-0.4.1/pycodex_router/browser.py +0 -175
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/.gitignore +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/LICENSE +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/README.md +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/__init__.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/__main__.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_ws/__init__.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_ws/feishu_card.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_ws/feishu_link.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/content_views.css +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/content_views.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/composer.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/content_views.css +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/content_views.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/owned_process.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/workspace.html +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/workspace_views.py +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/workspaces.html +0 -0
- {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/workspaces.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pycodex-ws
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Browser workspace and optional Feishu integration for python-codex
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.7
|
|
7
7
|
Requires-Dist: pillow>=9.0.0
|
|
8
|
-
Requires-Dist: python-codex==0.4.
|
|
8
|
+
Requires-Dist: python-codex==0.4.2
|
|
9
9
|
Requires-Dist: websockets<16,>=11
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Browser access checks for trusted devices sharing a router origin."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import urlencode, urlsplit
|
|
6
|
+
|
|
7
|
+
from starlette.exceptions import HTTPException
|
|
8
|
+
from starlette.requests import HTTPConnection
|
|
9
|
+
from starlette.responses import JSONResponse, RedirectResponse
|
|
10
|
+
|
|
11
|
+
from .protocol import route_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def request_origin(connection):
|
|
15
|
+
try:
|
|
16
|
+
parsed = urlsplit("//" + connection.headers["host"])
|
|
17
|
+
port = parsed.port
|
|
18
|
+
except ValueError:
|
|
19
|
+
raise HTTPException(400, "Invalid router host.")
|
|
20
|
+
host = parsed.hostname
|
|
21
|
+
if (
|
|
22
|
+
not host
|
|
23
|
+
or parsed.username is not None
|
|
24
|
+
or parsed.password is not None
|
|
25
|
+
or parsed.path
|
|
26
|
+
or parsed.query
|
|
27
|
+
or parsed.fragment
|
|
28
|
+
):
|
|
29
|
+
raise HTTPException(400, "Invalid router host.")
|
|
30
|
+
try:
|
|
31
|
+
ipaddress.ip_address(host)
|
|
32
|
+
except ValueError:
|
|
33
|
+
if len(host) > 253 or any(
|
|
34
|
+
not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
|
|
35
|
+
for label in host.split(".")
|
|
36
|
+
):
|
|
37
|
+
raise HTTPException(400, "Invalid router host.")
|
|
38
|
+
scheme = "https" if connection.scope["scheme"] in {"https", "wss"} else "http"
|
|
39
|
+
suffix = "" if port in (None, 443 if scheme == "https" else 80) else ":" + str(port)
|
|
40
|
+
authority = "[" + host + "]" if ":" in host else host
|
|
41
|
+
return scheme + "://" + authority + suffix
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BrowserAccess:
|
|
45
|
+
def __init__(self, router, app):
|
|
46
|
+
self.router = router
|
|
47
|
+
self.app = app
|
|
48
|
+
|
|
49
|
+
async def __call__(self, scope, receive, send):
|
|
50
|
+
connection = HTTPConnection(scope)
|
|
51
|
+
origin = request_origin(connection)
|
|
52
|
+
|
|
53
|
+
async def secured_send(message):
|
|
54
|
+
if message["type"] == "http.response.start":
|
|
55
|
+
message = dict(
|
|
56
|
+
message,
|
|
57
|
+
headers=list(message["headers"])
|
|
58
|
+
+ [
|
|
59
|
+
(b"content-security-policy", b"frame-ancestors 'self'"),
|
|
60
|
+
(b"x-frame-options", b"SAMEORIGIN"),
|
|
61
|
+
(b"x-content-type-options", b"nosniff"),
|
|
62
|
+
(b"referrer-policy", b"no-referrer"),
|
|
63
|
+
(b"permissions-policy", b"document-domain=()"),
|
|
64
|
+
(b"origin-agent-cluster", b"?1"),
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
await send(message)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
if connection.client.host in self.router.blocked_ips:
|
|
71
|
+
raise HTTPException(
|
|
72
|
+
403,
|
|
73
|
+
"Too many incorrect passwords. This IP is blocked "
|
|
74
|
+
"until the router restarts.",
|
|
75
|
+
)
|
|
76
|
+
if connection.headers.get("origin") not in (None, origin):
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
403, "Cross-origin router requests are not allowed."
|
|
79
|
+
)
|
|
80
|
+
await self.app(scope, receive, secured_send)
|
|
81
|
+
except HTTPException as exc:
|
|
82
|
+
if scope["type"] == "websocket":
|
|
83
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
84
|
+
else:
|
|
85
|
+
await JSONResponse({"error": exc.detail}, exc.status_code)(
|
|
86
|
+
scope, receive, secured_send
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def require_login(scope, receive, send):
|
|
91
|
+
connection = HTTPConnection(scope)
|
|
92
|
+
if scope["type"] == "websocket":
|
|
93
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
94
|
+
elif "/api/" in route_path(scope) or scope["method"] not in {"GET", "HEAD"}:
|
|
95
|
+
await JSONResponse({"error": "authentication required"}, 401)(
|
|
96
|
+
scope, receive, send
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
node_id = scope["path_params"]["node_id"]
|
|
100
|
+
prefix = scope.get("root_path", "")
|
|
101
|
+
root = prefix[: -len("/n/" + node_id)]
|
|
102
|
+
target = prefix + route_path(scope)
|
|
103
|
+
if connection.url.query:
|
|
104
|
+
target += "?" + connection.url.query
|
|
105
|
+
await RedirectResponse(
|
|
106
|
+
root + "/login?" + urlencode({"next": target}), 303
|
|
107
|
+
)(scope, receive, send)
|
|
@@ -4,9 +4,10 @@ import secrets
|
|
|
4
4
|
import socket
|
|
5
5
|
from contextlib import asynccontextmanager
|
|
6
6
|
|
|
7
|
-
from websockets.exceptions import ConnectionClosed, InvalidHandshake
|
|
7
|
+
from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatusCode
|
|
8
8
|
from websockets.legacy.client import connect
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
from .protocol import decode, device_id, encode, parse_target, send_event, tunnel_url
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class NodeIdInUse(Exception):
|
|
@@ -25,8 +26,9 @@ class RouterClient:
|
|
|
25
26
|
reconnect_seconds=1.0,
|
|
26
27
|
):
|
|
27
28
|
self.app = app
|
|
28
|
-
self.
|
|
29
|
-
self.
|
|
29
|
+
address, self._registration_key = parse_target(target)
|
|
30
|
+
self.control_url = tunnel_url(address, "control")
|
|
31
|
+
self.data_url = tunnel_url(address, "data")
|
|
30
32
|
self._identity_key = secrets.token_hex(32)
|
|
31
33
|
self.node_id = device_id(self._identity_key)
|
|
32
34
|
self.name = name
|
|
@@ -43,6 +45,7 @@ class RouterClient:
|
|
|
43
45
|
def _connect(self, url):
|
|
44
46
|
return connect(
|
|
45
47
|
url,
|
|
48
|
+
extra_headers={"Authorization": "Bearer " + self._registration_key},
|
|
46
49
|
open_timeout=10,
|
|
47
50
|
close_timeout=2,
|
|
48
51
|
ping_interval=20,
|
|
@@ -101,6 +104,8 @@ class RouterClient:
|
|
|
101
104
|
)
|
|
102
105
|
if isinstance(failure, ConnectionClosed) and failure.rcvd is not None:
|
|
103
106
|
reason += ", close_code={0}".format(failure.rcvd.code)
|
|
107
|
+
elif isinstance(failure, InvalidStatusCode):
|
|
108
|
+
reason += ", status_code={0}".format(failure.status_code)
|
|
104
109
|
self._logger.warning(
|
|
105
110
|
"Router node %s disconnected (%s); retrying in %g seconds",
|
|
106
111
|
self.name,
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
* { box-sizing: border-box; }
|
|
37
37
|
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink); font-size: 13px; }
|
|
38
38
|
a { color: inherit; text-decoration: none; }
|
|
39
|
-
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
|
|
39
|
+
a:focus-visible, .device-label:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
|
|
40
40
|
svg { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
|
41
|
-
h1, h2, h3, p { margin: 0; }
|
|
41
|
+
h1, h2, h3, h4, p { margin: 0; }
|
|
42
42
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
43
43
|
.page { width: min(1440px, calc(100% - 48px)); margin: 0 auto; padding-bottom: 32px; }
|
|
44
44
|
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--line); }
|
|
@@ -55,15 +55,26 @@
|
|
|
55
55
|
.intro { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 18px; padding: 24px 0; }
|
|
56
56
|
h1 { font-size: 24px; line-height: 1.3; font-weight: 650; letter-spacing: -.7px; }
|
|
57
57
|
.intro p { color: var(--muted); font-size: 12px; line-height: 1.6; }
|
|
58
|
+
.registration { max-width: 640px; margin-bottom: 24px; }
|
|
59
|
+
.registration summary { width: fit-content; color: var(--accent); cursor: pointer; }
|
|
60
|
+
.registration-content { display: grid; gap: 8px; margin-top: 12px; }
|
|
61
|
+
.registration input { width: 100%; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
62
|
+
.registration p { color: var(--muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
|
63
|
+
#alias-status { margin-bottom: 12px; color: var(--danger); font-size: 12px; }
|
|
64
|
+
#alias-status:empty { display: none; }
|
|
58
65
|
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 20px; align-items: start; }
|
|
59
66
|
.layout > * { min-width: 0; }
|
|
60
67
|
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
|
61
68
|
h2 { display: flex; align-items: center; gap: 9px; font-size: 13px; font-weight: 600; }
|
|
62
69
|
.count { min-width: 23px; padding: 2px 7px; border-radius: 6px; background: var(--soft); color: var(--muted); font-size: 11px; text-align: center; font-variant-numeric: tabular-nums; }
|
|
63
70
|
#activity { color: var(--green); font-size: 12px; }
|
|
71
|
+
#workspaces { display: grid; gap: 16px; }
|
|
64
72
|
.workspace-table, .nodes { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; }
|
|
65
|
-
.
|
|
66
|
-
.
|
|
73
|
+
.workspace-group-heading { display: flex; align-items: center; gap: 10px; padding: 12px 14px; }
|
|
74
|
+
.workspace-group-name { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; line-height: 1.6; overflow-wrap: anywhere; }
|
|
75
|
+
.workspace-group-heading .count { flex: 0 0 auto; }
|
|
76
|
+
.list-head, .workspace-row { display: grid; grid-template-columns: minmax(94px, .85fr) minmax(120px, 1.85fr) 60px 44px; gap: 12px; align-items: center; padding: 10px 14px; }
|
|
77
|
+
.list-head { position: sticky; top: 0; z-index: 1; background: var(--soft); color: var(--muted); font-size: 10px; font-weight: 550; letter-spacing: .3px; }
|
|
67
78
|
.workspace-row { min-height: 62px; border-top: 1px solid var(--line); }
|
|
68
79
|
.workspace-row > * { min-width: 0; }
|
|
69
80
|
.workspace-row:hover { background: var(--soft); }
|
|
@@ -74,8 +85,6 @@
|
|
|
74
85
|
.workspace-title { font-size: 12px; line-height: 1.6; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
75
86
|
.workspace-path, .state-path { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; line-height: 1.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
76
87
|
.state-path { color: var(--muted); }
|
|
77
|
-
.workspace-device { color: var(--muted); }
|
|
78
|
-
.workspace-device code { display: block; min-width: 0; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
79
88
|
.session-count { color: var(--muted); font-size: 11px; line-height: 1.7; }
|
|
80
89
|
.open-link, .manage-link { display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; min-height: 28px; color: var(--accent); font-size: 11px; font-weight: 550; white-space: nowrap; }
|
|
81
90
|
.open-link svg, .manage-link svg { width: 13px; height: 13px; }
|
|
@@ -85,6 +94,9 @@
|
|
|
85
94
|
.device-heading { display: flex; align-items: start; gap: 9px; }
|
|
86
95
|
.device-heading svg { width: 17px; height: 17px; margin-top: 1px; color: var(--muted); }
|
|
87
96
|
.device-name { flex: 1; min-width: 0; font-size: 11px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
|
|
97
|
+
.device-label { display: block; width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align: left; overflow-wrap: anywhere; cursor: text; }
|
|
98
|
+
.device-label[hidden] { display: none; }
|
|
99
|
+
.device-alias-input { width: 100%; min-width: 0; padding: 3px 6px; border: 1px solid var(--accent); border-radius: 4px; background: var(--panel); color: var(--ink); font: inherit; outline: none; }
|
|
88
100
|
.online-dot { margin-top: 7px; }
|
|
89
101
|
.device-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
|
|
90
102
|
.device-detail { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
|
@@ -104,9 +116,7 @@
|
|
|
104
116
|
.intro { padding: 20px 0; }
|
|
105
117
|
.list-head { display: none; }
|
|
106
118
|
.workspace-row { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px 10px; padding: 10px 12px; }
|
|
107
|
-
.workspace-row:first-child { border-top: 0; border-radius: 9px 9px 0 0; }
|
|
108
119
|
.workspace-paths { grid-column: 1 / -1; grid-row: 2; }
|
|
109
|
-
.workspace-device { grid-column: 1 / -1; grid-row: 3; }
|
|
110
120
|
.activity-badge { grid-column: 2; grid-row: 1; }
|
|
111
121
|
.open-link { grid-column: 3; grid-row: 1; min-height: 36px; }
|
|
112
122
|
}
|
|
@@ -130,19 +140,24 @@
|
|
|
130
140
|
<h1>Your workspaces</h1>
|
|
131
141
|
<p>Across your connected devices.</p>
|
|
132
142
|
</div>
|
|
143
|
+
<details id="device-connection" class="registration">
|
|
144
|
+
<summary>Connect a trusted device</summary>
|
|
145
|
+
<div class="registration-content">
|
|
146
|
+
<label for="registration-key">Registration key</label>
|
|
147
|
+
<input id="registration-key" readonly autocomplete="off" spellcheck="false">
|
|
148
|
+
<p id="registration-status" role="status"></p>
|
|
149
|
+
<p>On the device, use <code>pycodex-ws --listen-to KEY@ROUTER_ADDRESS:6000</code>. Replace <code>KEY</code> with this key and use your router's device port.</p>
|
|
150
|
+
</div>
|
|
151
|
+
</details>
|
|
152
|
+
<p id="alias-status" role="alert"></p>
|
|
133
153
|
<div class="layout">
|
|
134
154
|
<section aria-labelledby="workspace-heading">
|
|
135
155
|
<div class="section-heading">
|
|
136
156
|
<h2 id="workspace-heading">Workspaces <span id="workspace-count" class="count">—</span></h2>
|
|
137
157
|
<span id="activity"></span>
|
|
138
158
|
</div>
|
|
139
|
-
<div
|
|
140
|
-
<div class="
|
|
141
|
-
<span>Workspace</span><span>Directory / state</span><span>Device</span><span>Status</span><span></span>
|
|
142
|
-
</div>
|
|
143
|
-
<div id="workspaces" aria-busy="true">
|
|
144
|
-
<div class="empty"><h3>Loading workspaces…</h3></div>
|
|
145
|
-
</div>
|
|
159
|
+
<div id="workspaces" aria-busy="true">
|
|
160
|
+
<div class="empty"><h3>Loading workspaces…</h3></div>
|
|
146
161
|
</div>
|
|
147
162
|
</section>
|
|
148
163
|
<aside aria-labelledby="device-heading">
|
|
@@ -154,19 +169,28 @@
|
|
|
154
169
|
</div>
|
|
155
170
|
</main>
|
|
156
171
|
</div>
|
|
172
|
+
<template id="workspace-group-template">
|
|
173
|
+
<section class="workspace-table workspace-group">
|
|
174
|
+
<header class="workspace-group-heading">
|
|
175
|
+
<h3 class="workspace-group-name"><button type="button" class="device-label"></button></h3>
|
|
176
|
+
<span class="count"></span>
|
|
177
|
+
</header>
|
|
178
|
+
<div class="list-head" aria-hidden="true">
|
|
179
|
+
<span>Workspace</span><span>Directory / state</span><span>Status</span><span></span>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="workspace-group-list"></div>
|
|
182
|
+
</section>
|
|
183
|
+
</template>
|
|
157
184
|
<template id="workspace-template">
|
|
158
185
|
<article class="workspace-row">
|
|
159
186
|
<div class="workspace-main">
|
|
160
|
-
<
|
|
187
|
+
<h4 class="workspace-title"></h4>
|
|
161
188
|
<span class="session-count"></span>
|
|
162
189
|
</div>
|
|
163
190
|
<div class="workspace-paths">
|
|
164
191
|
<p class="workspace-path"></p>
|
|
165
192
|
<p class="state-path"></p>
|
|
166
193
|
</div>
|
|
167
|
-
<div class="workspace-device">
|
|
168
|
-
<code></code>
|
|
169
|
-
</div>
|
|
170
194
|
<span class="activity-badge"></span>
|
|
171
195
|
<a class="open-link">Open <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></a>
|
|
172
196
|
</article>
|
|
@@ -175,7 +199,7 @@
|
|
|
175
199
|
<article class="device">
|
|
176
200
|
<div class="device-heading">
|
|
177
201
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8m-4-4v4"/></svg>
|
|
178
|
-
<h3 class="device-name"><
|
|
202
|
+
<h3 class="device-name"><button type="button" class="device-label"></button></h3>
|
|
179
203
|
<span class="online-dot" aria-label="Online"></span>
|
|
180
204
|
</div>
|
|
181
205
|
<div class="device-footer">
|
|
@@ -198,11 +222,36 @@
|
|
|
198
222
|
const layout = document.querySelector(".layout");
|
|
199
223
|
const workspaces = document.getElementById("workspaces");
|
|
200
224
|
const nodes = document.getElementById("nodes");
|
|
225
|
+
const workspaceGroupTemplate = document.getElementById("workspace-group-template");
|
|
201
226
|
const workspaceTemplate = document.getElementById("workspace-template");
|
|
202
227
|
const deviceTemplate = document.getElementById("device-template");
|
|
203
228
|
const emptyTemplate = document.getElementById("empty-template");
|
|
229
|
+
const deviceConnection = document.getElementById("device-connection");
|
|
230
|
+
const registrationKey = document.getElementById("registration-key");
|
|
231
|
+
const registrationStatus = document.getElementById("registration-status");
|
|
232
|
+
const aliasStatus = document.getElementById("alias-status");
|
|
204
233
|
let refreshing = false;
|
|
205
234
|
let signature = "";
|
|
235
|
+
let editingDevice = null;
|
|
236
|
+
let aliasRevision = 0;
|
|
237
|
+
|
|
238
|
+
registrationKey.addEventListener("focus", () => registrationKey.select());
|
|
239
|
+
deviceConnection.addEventListener("toggle", async () => {
|
|
240
|
+
registrationKey.value = "";
|
|
241
|
+
registrationStatus.textContent = "";
|
|
242
|
+
if (!deviceConnection.open) return;
|
|
243
|
+
registrationStatus.textContent = "Loading…";
|
|
244
|
+
try {
|
|
245
|
+
const response = await fetch("api/registration-key", {cache: "no-store"});
|
|
246
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
247
|
+
const data = await response.json();
|
|
248
|
+
if (!deviceConnection.open) return;
|
|
249
|
+
registrationKey.value = data.key;
|
|
250
|
+
registrationStatus.textContent = "Select the key to copy it.";
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (deviceConnection.open) registrationStatus.textContent = `Could not load key · ${error.message}`;
|
|
253
|
+
}
|
|
254
|
+
});
|
|
206
255
|
|
|
207
256
|
function countLabel(count, label) {
|
|
208
257
|
return `${count} ${label}${count === 1 ? "" : "s"}`;
|
|
@@ -221,8 +270,77 @@
|
|
|
221
270
|
layout.dataset.stale = String(state === "error");
|
|
222
271
|
}
|
|
223
272
|
|
|
273
|
+
function bindDeviceLabel(label, node, data) {
|
|
274
|
+
label.textContent = node.alias || node.name;
|
|
275
|
+
label.title = `${node.name}\nClick to rename device`;
|
|
276
|
+
label.setAttribute("aria-label", `Rename device ${node.alias || node.name}`);
|
|
277
|
+
label.addEventListener("click", () => editDeviceAlias(label, node, data));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function editDeviceAlias(label, node, data) {
|
|
281
|
+
if (editingDevice !== null) return;
|
|
282
|
+
editingDevice = node.id;
|
|
283
|
+
aliasStatus.textContent = "";
|
|
284
|
+
const input = document.createElement("input");
|
|
285
|
+
input.type = "text";
|
|
286
|
+
input.className = "device-alias-input";
|
|
287
|
+
input.maxLength = 128;
|
|
288
|
+
input.setAttribute("aria-label", "Device alias");
|
|
289
|
+
input.title = "Leave empty to use the original device name.";
|
|
290
|
+
input.value = node.alias || "";
|
|
291
|
+
input.placeholder = node.name;
|
|
292
|
+
label.hidden = true;
|
|
293
|
+
label.before(input);
|
|
294
|
+
let finished = false;
|
|
295
|
+
const finish = async save => {
|
|
296
|
+
if (finished) return;
|
|
297
|
+
finished = true;
|
|
298
|
+
const alias = input.value.trim();
|
|
299
|
+
input.remove();
|
|
300
|
+
label.hidden = false;
|
|
301
|
+
try {
|
|
302
|
+
if (save && alias !== (node.alias || "")) {
|
|
303
|
+
const response = await fetch(`api/nodes/${encodeURIComponent(node.id)}/alias`, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: {"Content-Type": "application/json"},
|
|
306
|
+
body: JSON.stringify({alias}),
|
|
307
|
+
});
|
|
308
|
+
if (!response.ok) throw new Error(await response.text());
|
|
309
|
+
const result = await response.json();
|
|
310
|
+
node.alias = result.alias;
|
|
311
|
+
aliasRevision += 1;
|
|
312
|
+
render(data);
|
|
313
|
+
signature = JSON.stringify(data);
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
aliasStatus.textContent = `Could not rename device: ${error.message}`;
|
|
317
|
+
} finally {
|
|
318
|
+
editingDevice = null;
|
|
319
|
+
refresh();
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
input.addEventListener("keydown", event => {
|
|
323
|
+
if (event.isComposing) return;
|
|
324
|
+
if (event.key === "Enter" || event.key === "Escape") {
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
finish(event.key === "Enter");
|
|
327
|
+
label.focus();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
input.addEventListener("blur", () => finish(true));
|
|
331
|
+
input.focus();
|
|
332
|
+
input.select();
|
|
333
|
+
}
|
|
334
|
+
|
|
224
335
|
function render(data) {
|
|
225
336
|
const workspaceList = document.createDocumentFragment();
|
|
337
|
+
const groups = new Map();
|
|
338
|
+
for (const node of data.nodes) {
|
|
339
|
+
const group = workspaceGroupTemplate.content.firstElementChild.cloneNode(true);
|
|
340
|
+
group.setAttribute("aria-label", node.alias || node.name);
|
|
341
|
+
bindDeviceLabel(group.querySelector(".device-label"), node, data);
|
|
342
|
+
groups.set(node.id, {node, group});
|
|
343
|
+
}
|
|
226
344
|
for (const workspace of data.workspaces) {
|
|
227
345
|
const card = workspaceTemplate.content.firstElementChild.cloneNode(true);
|
|
228
346
|
const title = card.querySelector(".workspace-title");
|
|
@@ -234,17 +352,21 @@
|
|
|
234
352
|
const statePath = card.querySelector(".state-path");
|
|
235
353
|
statePath.textContent = workspace.state_path;
|
|
236
354
|
statePath.title = workspace.state_path;
|
|
237
|
-
const device = card.querySelector(".workspace-device code");
|
|
238
|
-
device.textContent = workspace.node_name;
|
|
239
|
-
device.title = workspace.node_name;
|
|
240
355
|
const activity = card.querySelector(".activity-badge");
|
|
241
356
|
activity.dataset.state = workspace.running ? "working" : "ready";
|
|
242
357
|
activity.textContent = workspace.running ? "Working" : "Ready";
|
|
243
358
|
card.querySelector(".session-count").textContent = countLabel(workspace.session_count, "session");
|
|
244
359
|
const link = card.querySelector(".open-link");
|
|
245
360
|
link.href = workspace.url;
|
|
246
|
-
|
|
247
|
-
|
|
361
|
+
const {node, group} = groups.get(workspace.node_id);
|
|
362
|
+
link.setAttribute("aria-label", `Open ${workspace.id} on ${node.alias || node.name}`);
|
|
363
|
+
group.querySelector(".workspace-group-list").append(card);
|
|
364
|
+
}
|
|
365
|
+
for (const {group} of groups.values()) {
|
|
366
|
+
const count = group.querySelector(".workspace-group-list").childElementCount;
|
|
367
|
+
if (!count) continue;
|
|
368
|
+
group.querySelector(".count").textContent = countLabel(count, "workspace");
|
|
369
|
+
workspaceList.append(group);
|
|
248
370
|
}
|
|
249
371
|
if (!data.workspaces.length) {
|
|
250
372
|
workspaceList.append(empty("No workspaces yet", data.nodes.length
|
|
@@ -255,13 +377,13 @@
|
|
|
255
377
|
const nodeList = document.createDocumentFragment();
|
|
256
378
|
for (const node of data.nodes) {
|
|
257
379
|
const card = deviceTemplate.content.firstElementChild.cloneNode(true);
|
|
258
|
-
card.querySelector(".device-
|
|
380
|
+
bindDeviceLabel(card.querySelector(".device-label"), node, data);
|
|
259
381
|
const owned = data.workspaces.filter(workspace => workspace.node_id === node.id);
|
|
260
382
|
const sessions = owned.reduce((total, workspace) => total + workspace.session_count, 0);
|
|
261
383
|
card.querySelector(".device-detail").textContent = `${countLabel(owned.length, "workspace")} · ${countLabel(sessions, "session")}`;
|
|
262
384
|
const link = card.querySelector(".manage-link");
|
|
263
385
|
link.href = node.url;
|
|
264
|
-
link.setAttribute("aria-label", `Manage workspaces on ${node.name}`);
|
|
386
|
+
link.setAttribute("aria-label", `Manage workspaces on ${node.alias || node.name}`);
|
|
265
387
|
nodeList.append(card);
|
|
266
388
|
}
|
|
267
389
|
if (!data.nodes.length) {
|
|
@@ -278,6 +400,7 @@
|
|
|
278
400
|
async function refresh() {
|
|
279
401
|
if (refreshing) return;
|
|
280
402
|
refreshing = true;
|
|
403
|
+
const revision = aliasRevision;
|
|
281
404
|
try {
|
|
282
405
|
const response = await fetch("api/workspaces", {cache: "no-store"});
|
|
283
406
|
if (response.status === 401) {
|
|
@@ -287,7 +410,7 @@
|
|
|
287
410
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
288
411
|
const data = await response.json();
|
|
289
412
|
const next = JSON.stringify(data);
|
|
290
|
-
if (next !== signature) {
|
|
413
|
+
if (editingDevice === null && revision === aliasRevision && next !== signature) {
|
|
291
414
|
render(data);
|
|
292
415
|
signature = next;
|
|
293
416
|
}
|
|
@@ -48,6 +48,33 @@ def tunnel_url(target, endpoint):
|
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def parse_target(target):
|
|
52
|
+
"""Separate a registration key from the address used on the wire."""
|
|
53
|
+
try:
|
|
54
|
+
has_scheme = "://" in target
|
|
55
|
+
parsed = urlsplit(target if has_scheme else "ws://" + target)
|
|
56
|
+
key = parsed.username
|
|
57
|
+
if (
|
|
58
|
+
not key
|
|
59
|
+
or not re.fullmatch("[1-9A-HJ-NP-Za-km-z]{1,22}", key)
|
|
60
|
+
or parsed.password is not None
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("invalid registration key")
|
|
63
|
+
authority = parsed.netloc.rsplit("@", 1)[1]
|
|
64
|
+
if not has_scheme and parsed.port is None:
|
|
65
|
+
authority += ":6000"
|
|
66
|
+
address = urlunsplit(
|
|
67
|
+
(parsed.scheme, authority, parsed.path, parsed.query, parsed.fragment)
|
|
68
|
+
)
|
|
69
|
+
tunnel_url(address, "control")
|
|
70
|
+
except ValueError:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"router address must be KEY@host[:port] or ws(s)://KEY@host[:port], "
|
|
73
|
+
"using the router-generated registration key"
|
|
74
|
+
) from None
|
|
75
|
+
return address, key
|
|
76
|
+
|
|
77
|
+
|
|
51
78
|
def encode(value):
|
|
52
79
|
def binary(item):
|
|
53
80
|
if isinstance(item, bytes):
|
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import tempfile
|
|
3
8
|
from contextlib import asynccontextmanager
|
|
4
9
|
from pathlib import Path
|
|
5
10
|
from uuid import uuid4
|
|
6
11
|
|
|
7
12
|
from starlette.applications import Starlette
|
|
13
|
+
from starlette.exceptions import HTTPException
|
|
8
14
|
from starlette.requests import HTTPConnection
|
|
9
15
|
from starlette.responses import HTMLResponse, JSONResponse
|
|
10
16
|
from starlette.routing import Mount, Route, WebSocketRoute
|
|
11
17
|
from starlette.websockets import WebSocketDisconnect, WebSocketState
|
|
12
|
-
from
|
|
18
|
+
from pycodex.portable.client import _base58_encode
|
|
19
|
+
from pycodex.utils.session_persist import resolve_codex_home
|
|
20
|
+
from workspace_server.app import (
|
|
21
|
+
_auth_cookie_matches,
|
|
22
|
+
_install_auth,
|
|
23
|
+
_json_object_body,
|
|
24
|
+
parse_listen,
|
|
25
|
+
)
|
|
13
26
|
from workspace_server.workspaces import normalize_workspace_id
|
|
14
27
|
|
|
15
|
-
from .browser import
|
|
28
|
+
from .browser import BrowserAccess, require_login
|
|
16
29
|
from .protocol import (
|
|
17
30
|
decode,
|
|
18
31
|
device_id,
|
|
@@ -27,6 +40,37 @@ class TunnelUnavailable(Exception):
|
|
|
27
40
|
pass
|
|
28
41
|
|
|
29
42
|
|
|
43
|
+
def load_registration_key(path):
|
|
44
|
+
path = Path(path)
|
|
45
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
46
|
+
try:
|
|
47
|
+
descriptor = os.open(
|
|
48
|
+
str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600
|
|
49
|
+
)
|
|
50
|
+
except FileExistsError:
|
|
51
|
+
value = path.read_bytes().strip()
|
|
52
|
+
else:
|
|
53
|
+
value = _base58_encode(os.urandom(16)).encode("ascii")
|
|
54
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
55
|
+
handle.write(value + b"\n")
|
|
56
|
+
if not re.fullmatch(b"[1-9A-HJ-NP-Za-km-z]{1,22}", value):
|
|
57
|
+
raise ValueError("invalid router registration key file")
|
|
58
|
+
if os.name == "posix" and path.stat().st_mode & 0o077:
|
|
59
|
+
raise ValueError("router registration key file must have owner-only permissions")
|
|
60
|
+
return value.decode("ascii")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_alias(value):
|
|
64
|
+
if (
|
|
65
|
+
not isinstance(value, str)
|
|
66
|
+
or len(value) > 128
|
|
67
|
+
or any(ord(char) < 32 for char in value)
|
|
68
|
+
):
|
|
69
|
+
raise ValueError("Device alias must be a single line of at most 128 characters.")
|
|
70
|
+
value.encode("utf-8")
|
|
71
|
+
return value.strip()
|
|
72
|
+
|
|
73
|
+
|
|
30
74
|
class Node:
|
|
31
75
|
def __init__(self, websocket, node_id, name, workspaces):
|
|
32
76
|
self.websocket = websocket
|
|
@@ -101,19 +145,94 @@ def validate_inventory(workspaces):
|
|
|
101
145
|
|
|
102
146
|
|
|
103
147
|
class Router:
|
|
104
|
-
def __init__(
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
connect_timeout=10,
|
|
151
|
+
max_connections=64,
|
|
152
|
+
registration_key=None,
|
|
153
|
+
aliases_path=None,
|
|
154
|
+
):
|
|
155
|
+
self.registration_key = (
|
|
156
|
+
_base58_encode(os.urandom(16))
|
|
157
|
+
if registration_key is None
|
|
158
|
+
else registration_key
|
|
159
|
+
)
|
|
160
|
+
if not isinstance(self.registration_key, str) or not re.fullmatch(
|
|
161
|
+
"[1-9A-HJ-NP-Za-km-z]{1,22}", self.registration_key
|
|
162
|
+
):
|
|
163
|
+
raise ValueError("invalid router registration key")
|
|
105
164
|
self.connect_timeout = connect_timeout
|
|
106
165
|
self.max_connections = max_connections
|
|
107
166
|
self.nodes = {}
|
|
108
167
|
self.streams = {}
|
|
109
168
|
self.browser_token = ""
|
|
110
169
|
self.blocked_ips = set()
|
|
170
|
+
self.aliases_path = Path(aliases_path) if aliases_path is not None else None
|
|
171
|
+
self.aliases = {}
|
|
172
|
+
if self.aliases_path is not None and self.aliases_path.exists():
|
|
173
|
+
aliases = json.loads(self.aliases_path.read_text(encoding="utf-8"))
|
|
174
|
+
if not isinstance(aliases, dict) or any(
|
|
175
|
+
not re.fullmatch("[a-z2-7]{52}", node_id)
|
|
176
|
+
or not alias
|
|
177
|
+
or normalize_alias(alias) != alias
|
|
178
|
+
for node_id, alias in aliases.items()
|
|
179
|
+
):
|
|
180
|
+
raise ValueError("invalid router aliases file")
|
|
181
|
+
self.aliases = aliases
|
|
182
|
+
|
|
183
|
+
def _save_aliases(self, aliases):
|
|
184
|
+
if self.aliases_path is not None:
|
|
185
|
+
path = self.aliases_path
|
|
186
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
187
|
+
data = json.dumps(aliases, ensure_ascii=False, indent=2).encode("utf-8")
|
|
188
|
+
with tempfile.NamedTemporaryFile(
|
|
189
|
+
dir=str(path.parent), prefix=path.name + ".", delete=False
|
|
190
|
+
) as handle:
|
|
191
|
+
temporary_path = Path(handle.name)
|
|
192
|
+
try:
|
|
193
|
+
handle.write(data + b"\n")
|
|
194
|
+
handle.close()
|
|
195
|
+
os.replace(str(temporary_path), str(path))
|
|
196
|
+
finally:
|
|
197
|
+
if temporary_path.exists():
|
|
198
|
+
temporary_path.unlink()
|
|
199
|
+
self.aliases = aliases
|
|
111
200
|
|
|
112
|
-
async def
|
|
113
|
-
if
|
|
201
|
+
async def rename_device(self, request):
|
|
202
|
+
if not self.browser_token or not _auth_cookie_matches(
|
|
203
|
+
self.browser_token, request.cookies.get("pycodex_router_auth")
|
|
204
|
+
):
|
|
205
|
+
raise HTTPException(401, "Authentication required.")
|
|
206
|
+
node_id = request.path_params["node_id"]
|
|
207
|
+
if node_id not in self.nodes:
|
|
208
|
+
raise HTTPException(404, "Device is not connected.")
|
|
209
|
+
payload = await _json_object_body(request)
|
|
210
|
+
try:
|
|
211
|
+
alias = normalize_alias(payload.get("alias"))
|
|
212
|
+
except ValueError as exc:
|
|
213
|
+
raise HTTPException(422, str(exc))
|
|
214
|
+
aliases = dict(self.aliases)
|
|
215
|
+
if alias:
|
|
216
|
+
aliases[node_id] = alias
|
|
217
|
+
else:
|
|
218
|
+
aliases.pop(node_id, None)
|
|
219
|
+
self._save_aliases(aliases)
|
|
220
|
+
return JSONResponse({"alias": alias})
|
|
221
|
+
|
|
222
|
+
async def accept_tunnel(self, websocket):
|
|
223
|
+
authorization = websocket.headers.get("authorization", "").encode("utf-8")
|
|
224
|
+
expected = ("Bearer " + self.registration_key).encode("ascii")
|
|
225
|
+
if websocket.headers.get("origin") or not secrets.compare_digest(
|
|
226
|
+
authorization, expected
|
|
227
|
+
):
|
|
114
228
|
await websocket.close(code=1008)
|
|
115
|
-
return
|
|
229
|
+
return False
|
|
116
230
|
await websocket.accept()
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
async def control(self, websocket):
|
|
234
|
+
if not await self.accept_tunnel(websocket):
|
|
235
|
+
return
|
|
117
236
|
node = None
|
|
118
237
|
try:
|
|
119
238
|
message = await asyncio.wait_for(control_message(websocket), 10)
|
|
@@ -166,10 +285,8 @@ class Router:
|
|
|
166
285
|
stream.done.set()
|
|
167
286
|
|
|
168
287
|
async def data(self, websocket):
|
|
169
|
-
if
|
|
170
|
-
await websocket.close(code=1008)
|
|
288
|
+
if not await self.accept_tunnel(websocket):
|
|
171
289
|
return
|
|
172
|
-
await websocket.accept()
|
|
173
290
|
stream = None
|
|
174
291
|
try:
|
|
175
292
|
message = await asyncio.wait_for(control_message(websocket), 10)
|
|
@@ -307,12 +424,13 @@ class Router:
|
|
|
307
424
|
nodes = []
|
|
308
425
|
workspaces = []
|
|
309
426
|
for node in sorted(self.nodes.values(), key=lambda item: item.id):
|
|
310
|
-
base =
|
|
311
|
-
|
|
312
|
-
node.id,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
427
|
+
base = request.scope.get("root_path", "") + "/n/" + node.id + "/"
|
|
428
|
+
nodes.append({
|
|
429
|
+
"id": node.id,
|
|
430
|
+
"name": node.name,
|
|
431
|
+
"alias": self.aliases.get(node.id, ""),
|
|
432
|
+
"url": base,
|
|
433
|
+
})
|
|
316
434
|
for workspace in node.workspaces:
|
|
317
435
|
item = dict(workspace)
|
|
318
436
|
item.update(
|
|
@@ -330,8 +448,14 @@ class Router:
|
|
|
330
448
|
await close_websocket(node.websocket, 1001)
|
|
331
449
|
|
|
332
450
|
|
|
333
|
-
def create_app(
|
|
334
|
-
|
|
451
|
+
def create_app(
|
|
452
|
+
password=None,
|
|
453
|
+
connect_timeout=10,
|
|
454
|
+
max_connections=64,
|
|
455
|
+
registration_key=None,
|
|
456
|
+
aliases_path=None,
|
|
457
|
+
):
|
|
458
|
+
router = Router(connect_timeout, max_connections, registration_key, aliases_path)
|
|
335
459
|
|
|
336
460
|
async def index(request):
|
|
337
461
|
return HTMLResponse(
|
|
@@ -339,11 +463,22 @@ def create_app(password=None, connect_timeout=10, max_connections=64):
|
|
|
339
463
|
headers={"Cache-Control": "no-store"},
|
|
340
464
|
)
|
|
341
465
|
|
|
466
|
+
async def registration_key_info(request):
|
|
467
|
+
if not router.browser_token or not _auth_cookie_matches(
|
|
468
|
+
router.browser_token, request.cookies.get("pycodex_router_auth")
|
|
469
|
+
):
|
|
470
|
+
return JSONResponse({"error": "authentication required"}, status_code=401)
|
|
471
|
+
return JSONResponse(
|
|
472
|
+
{"key": router.registration_key},
|
|
473
|
+
headers={"Cache-Control": "no-store"},
|
|
474
|
+
)
|
|
475
|
+
|
|
342
476
|
web = Starlette(
|
|
343
477
|
routes=[
|
|
344
478
|
Route("/", index),
|
|
345
479
|
Route("/api/workspaces", router.workspaces),
|
|
346
|
-
|
|
480
|
+
Route("/api/registration-key", registration_key_info),
|
|
481
|
+
Route("/api/nodes/{node_id}/alias", router.rename_device, methods=["POST"]),
|
|
347
482
|
]
|
|
348
483
|
)
|
|
349
484
|
router.browser_token = _install_auth(
|
|
@@ -361,9 +496,14 @@ def create_app(password=None, connect_timeout=10, max_connections=64):
|
|
|
361
496
|
finally:
|
|
362
497
|
await router.close()
|
|
363
498
|
|
|
364
|
-
|
|
499
|
+
browser = Starlette(
|
|
500
|
+
routes=[
|
|
501
|
+
Mount("/n/{node_id}", app=router.proxy),
|
|
502
|
+
Mount("/", app=web),
|
|
503
|
+
]
|
|
504
|
+
)
|
|
365
505
|
app = Starlette(
|
|
366
|
-
routes=[Mount("/", app=
|
|
506
|
+
routes=[Mount("/", app=BrowserAccess(router, browser))],
|
|
367
507
|
lifespan=lifespan,
|
|
368
508
|
)
|
|
369
509
|
app.state.router = router
|
|
@@ -384,8 +524,9 @@ def build_parser():
|
|
|
384
524
|
prog="pycodex-router",
|
|
385
525
|
description="Publish reverse-connected pycodex workspaces.",
|
|
386
526
|
epilog=(
|
|
387
|
-
"
|
|
388
|
-
"
|
|
527
|
+
"Sign in to the router page to copy its device registration key. "
|
|
528
|
+
"Clients connect with --listen-to KEY@host:port; registered devices "
|
|
529
|
+
"and their pages are trusted and share the router's browser origin."
|
|
389
530
|
),
|
|
390
531
|
)
|
|
391
532
|
parser.add_argument("--listen", default="0.0.0.0:6007", help="Browser host:port.")
|
|
@@ -407,13 +548,47 @@ def main(argv=None):
|
|
|
407
548
|
args = parser.parse_args(argv)
|
|
408
549
|
if not args.password:
|
|
409
550
|
parser.error("--password must not be empty")
|
|
410
|
-
app = create_app(args.password)
|
|
411
|
-
tunnel_app = create_tunnel_app(app.state.router)
|
|
412
551
|
addresses = [parse_listen(args.listen), parse_listen(args.tunnel_listen)]
|
|
413
552
|
if addresses[0][1] != 0 and addresses[0][1] == addresses[1][1]:
|
|
414
553
|
parser.error("browser and device listeners must use different ports")
|
|
554
|
+
codex_home = resolve_codex_home()
|
|
555
|
+
key_path = codex_home / "router.key"
|
|
556
|
+
app = create_app(
|
|
557
|
+
args.password,
|
|
558
|
+
registration_key=load_registration_key(key_path),
|
|
559
|
+
aliases_path=codex_home / "router-aliases.json",
|
|
560
|
+
)
|
|
561
|
+
tunnel_app = create_tunnel_app(app.state.router)
|
|
415
562
|
sockets = []
|
|
416
563
|
|
|
564
|
+
class RouterServer(uvicorn.Server):
|
|
565
|
+
async def startup(self, sockets=None):
|
|
566
|
+
await super().startup(sockets=sockets)
|
|
567
|
+
if not self.started:
|
|
568
|
+
return
|
|
569
|
+
host = addresses[1][0]
|
|
570
|
+
wildcard = host in {"0.0.0.0", "::"}
|
|
571
|
+
if wildcard:
|
|
572
|
+
host = "ROUTER_HOST"
|
|
573
|
+
elif ":" in host:
|
|
574
|
+
host = "[" + host + "]"
|
|
575
|
+
target = "{0}@{1}:{2}".format(
|
|
576
|
+
app.state.router.registration_key,
|
|
577
|
+
host,
|
|
578
|
+
sockets[1].getsockname()[1],
|
|
579
|
+
)
|
|
580
|
+
print(
|
|
581
|
+
"Device registration key stored in {0}\n"
|
|
582
|
+
"Connect a device:\n"
|
|
583
|
+
" pycodex-ws --listen-to {1}".format(key_path, target),
|
|
584
|
+
flush=True,
|
|
585
|
+
)
|
|
586
|
+
if wildcard:
|
|
587
|
+
print(
|
|
588
|
+
"Replace ROUTER_HOST with this router's reachable IP or hostname.",
|
|
589
|
+
flush=True,
|
|
590
|
+
)
|
|
591
|
+
|
|
417
592
|
async def dispatch(scope, receive, send):
|
|
418
593
|
# The accepted socket's local port cannot be forged with HTTP headers.
|
|
419
594
|
target = (
|
|
@@ -442,7 +617,7 @@ def main(argv=None):
|
|
|
442
617
|
config = listener
|
|
443
618
|
if sockets[0].getsockname()[1] == sockets[1].getsockname()[1]:
|
|
444
619
|
raise ValueError("browser and device listeners must use different ports")
|
|
445
|
-
|
|
620
|
+
RouterServer(config).run(sockets=sockets)
|
|
446
621
|
finally:
|
|
447
622
|
for listener_socket in sockets:
|
|
448
623
|
listener_socket.close()
|
|
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycodex-ws"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.2"
|
|
8
8
|
description = "Browser workspace and optional Feishu integration for python-codex"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.7"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"python-codex==0.4.
|
|
12
|
+
"python-codex==0.4.2",
|
|
13
13
|
"pillow>=9.0.0",
|
|
14
14
|
"websockets>=11,<16",
|
|
15
15
|
]
|
|
@@ -109,7 +109,10 @@ def build_parser() -> "argparse.ArgumentParser":
|
|
|
109
109
|
)
|
|
110
110
|
parser.add_argument(
|
|
111
111
|
"--listen-to",
|
|
112
|
-
help=
|
|
112
|
+
help=(
|
|
113
|
+
"Connect outward with the router registration key: "
|
|
114
|
+
"KEY@host[:port] (default port 6000) or ws(s)://KEY@host[:port]."
|
|
115
|
+
),
|
|
113
116
|
)
|
|
114
117
|
parser.add_argument(
|
|
115
118
|
"--workspace-config",
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
"""Separate device origins while sharing the router's HttpOnly login."""
|
|
2
|
-
|
|
3
|
-
import ipaddress
|
|
4
|
-
import re
|
|
5
|
-
from http.cookies import SimpleCookie
|
|
6
|
-
from urllib.parse import urlencode, urlsplit
|
|
7
|
-
|
|
8
|
-
from starlette.exceptions import HTTPException
|
|
9
|
-
from starlette.requests import HTTPConnection
|
|
10
|
-
from starlette.responses import JSONResponse, RedirectResponse
|
|
11
|
-
|
|
12
|
-
from .protocol import route_path
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def origins(connection):
|
|
16
|
-
try:
|
|
17
|
-
parsed = urlsplit("//" + connection.headers["host"])
|
|
18
|
-
port = parsed.port
|
|
19
|
-
except ValueError:
|
|
20
|
-
raise HTTPException(400, "Invalid router host.")
|
|
21
|
-
host = parsed.hostname
|
|
22
|
-
if (
|
|
23
|
-
not host
|
|
24
|
-
or parsed.username is not None
|
|
25
|
-
or parsed.password is not None
|
|
26
|
-
or parsed.path
|
|
27
|
-
or parsed.query
|
|
28
|
-
or parsed.fragment
|
|
29
|
-
):
|
|
30
|
-
raise HTTPException(400, "Invalid router host.")
|
|
31
|
-
try:
|
|
32
|
-
ipaddress.ip_address(host)
|
|
33
|
-
except ValueError:
|
|
34
|
-
if len(host) > 253 or any(
|
|
35
|
-
not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
|
|
36
|
-
for label in host.split(".")
|
|
37
|
-
):
|
|
38
|
-
raise HTTPException(400, "Invalid router host.")
|
|
39
|
-
scheme = "https" if connection.scope["scheme"] in {"https", "wss"} else "http"
|
|
40
|
-
suffix = "" if port in (None, 443 if scheme == "https" else 80) else ":" + str(port)
|
|
41
|
-
authority = "[" + host + "]" if ":" in host else host
|
|
42
|
-
origin = scheme + "://" + authority + suffix
|
|
43
|
-
first, separator, parent = host.partition(".")
|
|
44
|
-
node_id = first[2:] if separator and re.fullmatch("n-[a-z2-7]{52}", first) else None
|
|
45
|
-
base = scheme + "://" + parent + suffix if node_id else origin
|
|
46
|
-
return origin, base, node_id
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def device_url(connection, node_id, path):
|
|
50
|
-
if not re.fullmatch("[a-z2-7]{52}", node_id):
|
|
51
|
-
raise HTTPException(404, "Invalid device id.")
|
|
52
|
-
unused_origin, base, unused_node = origins(connection)
|
|
53
|
-
parsed = urlsplit(base)
|
|
54
|
-
if len("n-" + node_id + "." + parsed.hostname) > 253:
|
|
55
|
-
raise HTTPException(400, "Router domain is too long for device subdomains.")
|
|
56
|
-
try:
|
|
57
|
-
ipaddress.ip_address(parsed.hostname)
|
|
58
|
-
except ValueError:
|
|
59
|
-
pass
|
|
60
|
-
else:
|
|
61
|
-
raise HTTPException(
|
|
62
|
-
400,
|
|
63
|
-
"Device isolation requires a dedicated router domain with wildcard DNS "
|
|
64
|
-
"and, for HTTPS, a wildcard certificate; a bare IP cannot isolate pages.",
|
|
65
|
-
)
|
|
66
|
-
return parsed.scheme + "://n-" + node_id + "." + parsed.netloc + path
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class BrowserOrigins:
|
|
70
|
-
def __init__(self, router, admin_app, node_app):
|
|
71
|
-
self.router = router
|
|
72
|
-
self.admin_app = admin_app
|
|
73
|
-
self.node_app = node_app
|
|
74
|
-
|
|
75
|
-
async def __call__(self, scope, receive, send):
|
|
76
|
-
connection = HTTPConnection(scope)
|
|
77
|
-
origin, base, node_id = origins(connection)
|
|
78
|
-
path = route_path(scope)
|
|
79
|
-
|
|
80
|
-
async def secured_send(message):
|
|
81
|
-
if message["type"] == "http.response.start":
|
|
82
|
-
headers = []
|
|
83
|
-
for name, value in message["headers"]:
|
|
84
|
-
if name.lower() == b"set-cookie" and node_id is None:
|
|
85
|
-
# Only router-controlled subdomains share this cookie;
|
|
86
|
-
# request_scope() never forwards it to a device.
|
|
87
|
-
cookies = SimpleCookie()
|
|
88
|
-
cookies.load(value.decode("latin-1"))
|
|
89
|
-
if "pycodex_router_auth" in cookies:
|
|
90
|
-
cookie = cookies["pycodex_router_auth"]
|
|
91
|
-
try:
|
|
92
|
-
ipaddress.ip_address(connection.url.hostname)
|
|
93
|
-
except ValueError:
|
|
94
|
-
cookie["domain"] = connection.url.hostname
|
|
95
|
-
value = cookie.OutputString().encode("latin-1")
|
|
96
|
-
headers.append((name, value))
|
|
97
|
-
message = dict(
|
|
98
|
-
message,
|
|
99
|
-
headers=headers
|
|
100
|
-
+ [
|
|
101
|
-
(b"content-security-policy", b"frame-ancestors 'self'"),
|
|
102
|
-
(b"x-frame-options", b"SAMEORIGIN"),
|
|
103
|
-
(b"x-content-type-options", b"nosniff"),
|
|
104
|
-
(b"referrer-policy", b"no-referrer"),
|
|
105
|
-
(b"permissions-policy", b"document-domain=()"),
|
|
106
|
-
(b"origin-agent-cluster", b"?1"),
|
|
107
|
-
],
|
|
108
|
-
)
|
|
109
|
-
await send(message)
|
|
110
|
-
|
|
111
|
-
try:
|
|
112
|
-
if connection.client.host in self.router.blocked_ips:
|
|
113
|
-
raise HTTPException(
|
|
114
|
-
403,
|
|
115
|
-
"Too many incorrect passwords. This IP is blocked "
|
|
116
|
-
"until the router restarts.",
|
|
117
|
-
)
|
|
118
|
-
if connection.headers.get("origin") not in (None, origin):
|
|
119
|
-
raise HTTPException(
|
|
120
|
-
403, "Cross-origin router requests are not allowed."
|
|
121
|
-
)
|
|
122
|
-
if node_id:
|
|
123
|
-
prefix = "/n/" + node_id
|
|
124
|
-
if path != prefix and not path.startswith(prefix + "/"):
|
|
125
|
-
raise HTTPException(
|
|
126
|
-
404, "Workspace route not found on this origin."
|
|
127
|
-
)
|
|
128
|
-
await self.node_app(scope, receive, secured_send)
|
|
129
|
-
else:
|
|
130
|
-
await self.admin_app(scope, receive, secured_send)
|
|
131
|
-
except HTTPException as exc:
|
|
132
|
-
if scope["type"] == "websocket":
|
|
133
|
-
await send({"type": "websocket.close", "code": 1008})
|
|
134
|
-
else:
|
|
135
|
-
await JSONResponse({"error": exc.detail}, exc.status_code)(
|
|
136
|
-
scope, receive, secured_send
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
async def redirect_device(scope, receive, send):
|
|
141
|
-
if scope["type"] != "http" or scope["method"] not in {"GET", "HEAD"}:
|
|
142
|
-
if scope["type"] == "websocket":
|
|
143
|
-
await send({"type": "websocket.close", "code": 1008})
|
|
144
|
-
else:
|
|
145
|
-
await JSONResponse({"error": "Use the device's own origin."}, 403)(
|
|
146
|
-
scope, receive, send
|
|
147
|
-
)
|
|
148
|
-
return
|
|
149
|
-
connection = HTTPConnection(scope)
|
|
150
|
-
target = scope.get("root_path", "") + route_path(scope)
|
|
151
|
-
if connection.url.query:
|
|
152
|
-
target += "?" + connection.url.query
|
|
153
|
-
await RedirectResponse(
|
|
154
|
-
device_url(connection, scope["path_params"]["node_id"], target), 307
|
|
155
|
-
)(scope, receive, send)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
async def require_login(scope, receive, send):
|
|
159
|
-
connection = HTTPConnection(scope)
|
|
160
|
-
if scope["type"] == "websocket":
|
|
161
|
-
await send({"type": "websocket.close", "code": 1008})
|
|
162
|
-
elif "/api/" in route_path(scope) or scope["method"] not in {"GET", "HEAD"}:
|
|
163
|
-
await JSONResponse({"error": "authentication required"}, 401)(
|
|
164
|
-
scope, receive, send
|
|
165
|
-
)
|
|
166
|
-
else:
|
|
167
|
-
unused_origin, base, node_id = origins(connection)
|
|
168
|
-
prefix = scope.get("root_path", "")
|
|
169
|
-
root = prefix[: -len("/n/" + node_id)]
|
|
170
|
-
target = prefix + route_path(scope)
|
|
171
|
-
if connection.url.query:
|
|
172
|
-
target += "?" + connection.url.query
|
|
173
|
-
await RedirectResponse(
|
|
174
|
-
base + root + "/login?" + urlencode({"next": target}), 303
|
|
175
|
-
)(scope, receive, send)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|