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.
Files changed (41) hide show
  1. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/PKG-INFO +2 -2
  2. pycodex_ws-0.4.2/pycodex_router/browser.py +107 -0
  3. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/client.py +9 -4
  4. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/index.html +151 -28
  5. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/protocol.py +27 -0
  6. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/server.py +200 -25
  7. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pyproject.toml +2 -2
  8. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/app.py +4 -1
  9. pycodex_ws-0.4.1/pycodex_router/browser.py +0 -175
  10. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/.gitignore +0 -0
  11. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/LICENSE +0 -0
  12. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/README.md +0 -0
  13. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/__init__.py +0 -0
  14. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_router/__main__.py +0 -0
  15. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_ws/__init__.py +0 -0
  16. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_ws/feishu_card.py +0 -0
  17. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_ws/feishu_link.py +0 -0
  18. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/pycodex_ws/feishu_oauth.py +0 -0
  19. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/__init__.py +0 -0
  20. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/__main__.py +0 -0
  21. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/content_views.LICENSE.txt +0 -0
  22. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/content_views.css +0 -0
  23. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/content_views.mjs +0 -0
  24. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/editor.css +0 -0
  25. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/editor.mjs +0 -0
  26. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/.gitignore +0 -0
  27. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/build.mjs +0 -0
  28. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/composer.mjs +0 -0
  29. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/content_views.css +0 -0
  30. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/content_views.mjs +0 -0
  31. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/editor.css +0 -0
  32. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/editor.mjs +0 -0
  33. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/editor_context.mjs +0 -0
  34. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/package-lock.json +0 -0
  35. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/package.json +0 -0
  36. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/frontend/view_labels.mjs +0 -0
  37. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/owned_process.py +0 -0
  38. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/workspace.html +0 -0
  39. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/workspace_views.py +0 -0
  40. {pycodex_ws-0.4.1 → pycodex_ws-0.4.2}/workspace_server/workspaces.html +0 -0
  41. {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.1
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.1
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
- from .protocol import decode, device_id, encode, send_event, tunnel_url
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.control_url = tunnel_url(target, "control")
29
- self.data_url = tunnel_url(target, "data")
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
- .list-head, .workspace-row { display: grid; grid-template-columns: minmax(94px, .85fr) minmax(120px, 1.35fr) minmax(110px, 1fr) 60px 44px; gap: 12px; align-items: center; padding: 10px 14px; }
66
- .list-head { position: sticky; top: 0; z-index: 1; border-radius: 9px 9px 0 0; background: var(--soft); color: var(--muted); font-size: 10px; font-weight: 550; letter-spacing: .3px; }
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 class="workspace-table">
140
- <div class="list-head" aria-hidden="true">
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
- <h3 class="workspace-title"></h3>
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"><code></code></h3>
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
- link.setAttribute("aria-label", `Open ${workspace.id} on ${workspace.node_name}`);
247
- workspaceList.append(card);
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-name code").textContent = node.name;
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 workspace_server.app import _auth_cookie_matches, _install_auth, parse_listen
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 BrowserOrigins, device_url, redirect_device, require_login
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__(self, connect_timeout=10, max_connections=64):
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 control(self, websocket):
113
- if websocket.headers.get("origin"):
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 websocket.headers.get("origin"):
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 = device_url(
311
- request,
312
- node.id,
313
- request.scope.get("root_path", "") + "/n/" + node.id + "/",
314
- )
315
- nodes.append({"id": node.id, "name": node.name, "url": base})
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(password=None, connect_timeout=10, max_connections=64):
334
- router = Router(connect_timeout, max_connections)
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
- Mount("/n/{node_id}", app=redirect_device),
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
- node_web = Starlette(routes=[Mount("/n/{node_id}", app=router.proxy)])
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=BrowserOrigins(router, web, node_web))],
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
- "Use a dedicated router domain with wildcard DNS; terminate HTTPS/WSS "
388
- "and the wildcard certificate at your reverse proxy."
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
- uvicorn.Server(config).run(sockets=sockets)
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.1"
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.1",
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="Connect outward to a pycodex-router host:port or ws(s) URL.",
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