dsh-plugin-weapp-gateway 0.8.23 → 0.8.34
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.
- package/LICENSE +21 -21
- package/PROTOCOL.md +1545 -1486
- package/README.md +503 -464
- package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -31
- package/bin/setup-ip.mjs +474 -474
- package/cordis.patch.yml +50 -50
- package/docs/agent-team-app-integration.md +945 -0
- package/docs/blog-mobile-gateway.md +542 -542
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -255
- package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -122
- package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -52
- package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -109
- package/docs/dsh-rc2-mobile-integration.md +155 -155
- package/docs/file-upload-app-integration.md +250 -0
- package/docs/multi-gateway-app-integration.md +141 -125
- package/docs/multi-gateway-phase1-acceptance.md +179 -179
- package/docs/multi-gateway-todo.md +137 -137
- package/docs/remote-gateway-refactor-plan.md +52 -52
- package/docs/runtime-architecture.architecture.json +264 -264
- package/docs/runtime-architecture.html +15001 -15001
- package/docs/runtime-architecture.visual-check.html +32 -32
- package/docs/runtime-architecture.visual-check.json +548 -548
- package/docs/session-agent-preset-app-integration.md +93 -93
- package/docs/turn-outline-app-integration.md +196 -0
- package/docs/typert-remote-gateway-feature-checklist.md +294 -294
- package/docs/voice-input-implementation.md +219 -219
- package/docs/workspace-changes-mobile-integration.md +32 -32
- package/helper/dsh_mobile_gateway_helper.py +227 -227
- package/lib/account-bridge.mjs +57 -57
- package/lib/client.js +1409 -1361
- package/lib/cloudflare-tunnel.mjs +314 -314
- package/lib/cloudflared-binary.mjs +135 -135
- package/lib/context-bridge.mjs +3338 -1715
- package/lib/cost-meter-bridge.mjs +794 -794
- package/lib/cost-meter-dashboard-bridge.mjs +1655 -1449
- package/lib/devices.js +273 -245
- package/lib/dsh-host-adapter.mjs +508 -426
- package/lib/file-upload.mjs +205 -0
- package/lib/gateway-probe.mjs +6 -6
- package/lib/gateway-state.mjs +57 -57
- package/lib/gateway-tools.mjs +36 -36
- package/lib/history-cache.mjs +66 -0
- package/lib/index.mjs +4390 -3803
- package/lib/plugin-management.mjs +251 -233
- package/lib/public-endpoint-check.mjs +61 -61
- package/lib/relay-connector.mjs +213 -181
- package/lib/relay-state.mjs +83 -76
- package/lib/session-follower.mjs +140 -136
- package/lib/session-resume-bridge.mjs +86 -0
- package/lib/session-target.mjs +29 -0
- package/lib/speech-input.mjs +192 -192
- package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -123
- package/lib/vendor/cost-meter/coding-plans.mjs +1668 -1668
- package/lib/vendor/cost-meter/credentials-shim.mjs +8 -8
- package/lib/vendor/cost-meter/custom-balance.mjs +281 -281
- package/lib/vendor/cost-meter/factory-quota.mjs +69 -69
- package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -1006
- package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -664
- package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -13
- package/lib/vendor/cost-meter/net.mjs +183 -183
- package/lib/vendor/cost-meter/opendesign.mjs +204 -204
- package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -95
- package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -44
- package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -38
- package/lib/vendor/cost-meter/workbuddy.mjs +356 -356
- package/lib/web-restart.mjs +47 -47
- package/lib/wire-json.mjs +7 -7
- package/lib/workspace-changes-bridge.mjs +32 -32
- package/package.json +54 -54
- package/relay/agent-connector.js +588 -569
- package/relay/vendor/tweetnacl.js +2391 -2391
|
@@ -1,227 +1,227 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# Managed by dsh-plugin-weapp-gateway
|
|
3
|
-
"""Root-only, deliberately narrow system helper for the mobile gateway."""
|
|
4
|
-
|
|
5
|
-
import argparse
|
|
6
|
-
import ipaddress
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import pathlib
|
|
10
|
-
import re
|
|
11
|
-
import shutil
|
|
12
|
-
import socketserver
|
|
13
|
-
import subprocess
|
|
14
|
-
import sys
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
VERSION = 1
|
|
18
|
-
MARKER = "# Managed by dsh-plugin-weapp-gateway"
|
|
19
|
-
SOCKET_PATH = "/run/dsh-mobile-gateway/helper.sock"
|
|
20
|
-
CONFIG_DIR = pathlib.Path("/etc/dsh-mobile-gateway")
|
|
21
|
-
PUBLIC_URL_FILE = CONFIG_DIR / "public-url"
|
|
22
|
-
NGINX_CONFIG = pathlib.Path("/etc/nginx/conf.d/dsh-mobile-gateway.conf")
|
|
23
|
-
WEBROOT = pathlib.Path("/var/lib/dsh-mobile-gateway/acme")
|
|
24
|
-
CERTBOT_HOME = pathlib.Path("/opt/dsh-mobile-gateway/certbot")
|
|
25
|
-
CERTBOT = CERTBOT_HOME / "bin/certbot"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def run(args):
|
|
29
|
-
subprocess.run(args, check=True)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def atomic_managed_write(target, content, mode=0o644):
|
|
33
|
-
target = pathlib.Path(target)
|
|
34
|
-
if target.exists() and not target.read_text(encoding="utf-8").startswith(MARKER):
|
|
35
|
-
raise RuntimeError(f"refusing to overwrite unmanaged file: {target}")
|
|
36
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
-
temporary = target.with_name(f"{target.name}.tmp-{os.getpid()}")
|
|
38
|
-
temporary.write_text(content, encoding="utf-8")
|
|
39
|
-
os.chmod(temporary, mode)
|
|
40
|
-
os.replace(temporary, target)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def validate_public_ip(value):
|
|
44
|
-
try:
|
|
45
|
-
address = ipaddress.ip_address(value)
|
|
46
|
-
except ValueError as error:
|
|
47
|
-
raise ValueError("publicIp must be a valid IPv4 address") from error
|
|
48
|
-
if address.version != 4 or not address.is_global:
|
|
49
|
-
raise ValueError("publicIp must be a public IPv4 address")
|
|
50
|
-
return str(address)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def validate_port(value):
|
|
54
|
-
if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 65535:
|
|
55
|
-
raise ValueError("backendPort must be an integer between 1 and 65535")
|
|
56
|
-
return value
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def certificate_name(public_ip):
|
|
60
|
-
return f"dsh-mobile-gateway-{public_ip.replace('.', '-')}"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def nginx_http_config(public_ip):
|
|
64
|
-
return f"""{MARKER}
|
|
65
|
-
server {{
|
|
66
|
-
listen 80;
|
|
67
|
-
server_name {public_ip};
|
|
68
|
-
|
|
69
|
-
location ^~ /.well-known/acme-challenge/ {{
|
|
70
|
-
root {WEBROOT};
|
|
71
|
-
default_type text/plain;
|
|
72
|
-
}}
|
|
73
|
-
|
|
74
|
-
location / {{ return 404; }}
|
|
75
|
-
}}
|
|
76
|
-
"""
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def nginx_tls_config(public_ip, backend_port):
|
|
80
|
-
live = f"/etc/letsencrypt/live/{certificate_name(public_ip)}"
|
|
81
|
-
return nginx_http_config(public_ip) + f"""
|
|
82
|
-
server {{
|
|
83
|
-
listen 443 ssl;
|
|
84
|
-
server_name {public_ip};
|
|
85
|
-
server_tokens off;
|
|
86
|
-
access_log off;
|
|
87
|
-
|
|
88
|
-
ssl_certificate {live}/fullchain.pem;
|
|
89
|
-
ssl_certificate_key {live}/privkey.pem;
|
|
90
|
-
ssl_protocols TLSv1.2 TLSv1.3;
|
|
91
|
-
|
|
92
|
-
location = /ws/mobile {{
|
|
93
|
-
if ($args != \"\") {{ return 404; }}
|
|
94
|
-
proxy_pass http://127.0.0.1:{backend_port};
|
|
95
|
-
proxy_http_version 1.1;
|
|
96
|
-
proxy_set_header Host 127.0.0.1:{backend_port};
|
|
97
|
-
proxy_set_header Upgrade $http_upgrade;
|
|
98
|
-
proxy_set_header Connection \"upgrade\";
|
|
99
|
-
proxy_set_header X-Forwarded-Proto https;
|
|
100
|
-
proxy_read_timeout 3600s;
|
|
101
|
-
proxy_send_timeout 3600s;
|
|
102
|
-
proxy_buffering off;
|
|
103
|
-
}}
|
|
104
|
-
|
|
105
|
-
location / {{ return 404; }}
|
|
106
|
-
}}
|
|
107
|
-
"""
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def current_status():
|
|
111
|
-
public_url = None
|
|
112
|
-
if PUBLIC_URL_FILE.exists():
|
|
113
|
-
public_url = next((line.strip() for line in PUBLIC_URL_FILE.read_text(encoding="utf-8").splitlines()
|
|
114
|
-
if line.strip().startswith("wss://")), None)
|
|
115
|
-
backend_port = None
|
|
116
|
-
if NGINX_CONFIG.exists():
|
|
117
|
-
match = re.search(r"proxy_pass http://127\.0\.0\.1:(\d+);", NGINX_CONFIG.read_text(encoding="utf-8"))
|
|
118
|
-
if match:
|
|
119
|
-
backend_port = int(match.group(1))
|
|
120
|
-
return {
|
|
121
|
-
"installed": True,
|
|
122
|
-
"version": VERSION,
|
|
123
|
-
"configured": bool(public_url and backend_port),
|
|
124
|
-
"publicUrl": public_url,
|
|
125
|
-
"backendPort": backend_port,
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def ensure_dependencies():
|
|
130
|
-
if not shutil.which("nginx") or not CERTBOT.exists():
|
|
131
|
-
raise RuntimeError("helper dependencies are missing; rerun init to repair the installation")
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def configure(public_ip, backend_port):
|
|
135
|
-
public_ip = validate_public_ip(public_ip)
|
|
136
|
-
backend_port = validate_port(backend_port)
|
|
137
|
-
previous = NGINX_CONFIG.read_bytes() if NGINX_CONFIG.exists() else None
|
|
138
|
-
if previous is not None and not previous.decode("utf-8").startswith(MARKER):
|
|
139
|
-
raise RuntimeError(f"refusing to overwrite unmanaged file: {NGINX_CONFIG}")
|
|
140
|
-
|
|
141
|
-
ensure_dependencies()
|
|
142
|
-
(WEBROOT / ".well-known/acme-challenge").mkdir(parents=True, exist_ok=True)
|
|
143
|
-
try:
|
|
144
|
-
atomic_managed_write(NGINX_CONFIG, nginx_http_config(public_ip))
|
|
145
|
-
run(["nginx", "-t"])
|
|
146
|
-
run(["systemctl", "enable", "--now", "nginx"])
|
|
147
|
-
run(["systemctl", "reload", "nginx"])
|
|
148
|
-
|
|
149
|
-
args = [
|
|
150
|
-
str(CERTBOT), "certonly", "--non-interactive", "--agree-tos",
|
|
151
|
-
"--preferred-profile", "shortlived", "--webroot", "--webroot-path", str(WEBROOT),
|
|
152
|
-
"--ip-address", public_ip, "--cert-name", certificate_name(public_ip),
|
|
153
|
-
"--keep-until-expiring", "--register-unsafely-without-email",
|
|
154
|
-
]
|
|
155
|
-
run(args)
|
|
156
|
-
atomic_managed_write(NGINX_CONFIG, nginx_tls_config(public_ip, backend_port))
|
|
157
|
-
atomic_managed_write(PUBLIC_URL_FILE, f"{MARKER}\nwss://{public_ip}/ws/mobile\n")
|
|
158
|
-
run(["nginx", "-t"])
|
|
159
|
-
run(["systemctl", "reload", "nginx"])
|
|
160
|
-
except Exception:
|
|
161
|
-
if previous is None:
|
|
162
|
-
NGINX_CONFIG.unlink(missing_ok=True)
|
|
163
|
-
else:
|
|
164
|
-
NGINX_CONFIG.write_bytes(previous)
|
|
165
|
-
if shutil.which("nginx"):
|
|
166
|
-
subprocess.run(["nginx", "-t"], check=False)
|
|
167
|
-
subprocess.run(["systemctl", "reload", "nginx"], check=False)
|
|
168
|
-
raise
|
|
169
|
-
return current_status()
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def dispatch(message):
|
|
173
|
-
action = message.get("action")
|
|
174
|
-
if action == "status":
|
|
175
|
-
return current_status()
|
|
176
|
-
if action == "configure":
|
|
177
|
-
return configure(message.get("publicIp"), message.get("backendPort"))
|
|
178
|
-
raise ValueError("unsupported helper action")
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
class Handler(socketserver.StreamRequestHandler):
|
|
182
|
-
def handle(self):
|
|
183
|
-
try:
|
|
184
|
-
raw = self.rfile.readline(65537)
|
|
185
|
-
if not raw or len(raw) > 65536:
|
|
186
|
-
raise ValueError("invalid helper request")
|
|
187
|
-
message = json.loads(raw.decode("utf-8"))
|
|
188
|
-
if not isinstance(message, dict):
|
|
189
|
-
raise ValueError("helper request must be an object")
|
|
190
|
-
response = {"ok": True, "result": dispatch(message)}
|
|
191
|
-
except Exception as error: # Return a typed error; never expose a traceback.
|
|
192
|
-
response = {"ok": False, "error": str(error)}
|
|
193
|
-
self.wfile.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
class Server(socketserver.UnixStreamServer):
|
|
197
|
-
allow_reuse_address = True
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def serve(uid, socket_path):
|
|
201
|
-
if os.geteuid() != 0:
|
|
202
|
-
raise RuntimeError("helper must run as root")
|
|
203
|
-
path = pathlib.Path(socket_path)
|
|
204
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
-
path.unlink(missing_ok=True)
|
|
206
|
-
with Server(str(path), Handler) as server:
|
|
207
|
-
os.chown(path, uid, -1)
|
|
208
|
-
os.chmod(path, 0o600)
|
|
209
|
-
server.serve_forever()
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def main():
|
|
213
|
-
parser = argparse.ArgumentParser()
|
|
214
|
-
parser.add_argument("--uid", type=int, required=True)
|
|
215
|
-
parser.add_argument("--socket", default=SOCKET_PATH)
|
|
216
|
-
options = parser.parse_args()
|
|
217
|
-
if options.uid < 1:
|
|
218
|
-
raise ValueError("uid must identify a non-root user")
|
|
219
|
-
serve(options.uid, options.socket)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if __name__ == "__main__":
|
|
223
|
-
try:
|
|
224
|
-
main()
|
|
225
|
-
except Exception as error:
|
|
226
|
-
print(f"dsh-mobile-gateway-helper: {error}", file=sys.stderr)
|
|
227
|
-
raise SystemExit(1)
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Managed by dsh-plugin-weapp-gateway
|
|
3
|
+
"""Root-only, deliberately narrow system helper for the mobile gateway."""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import socketserver
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
VERSION = 1
|
|
18
|
+
MARKER = "# Managed by dsh-plugin-weapp-gateway"
|
|
19
|
+
SOCKET_PATH = "/run/dsh-mobile-gateway/helper.sock"
|
|
20
|
+
CONFIG_DIR = pathlib.Path("/etc/dsh-mobile-gateway")
|
|
21
|
+
PUBLIC_URL_FILE = CONFIG_DIR / "public-url"
|
|
22
|
+
NGINX_CONFIG = pathlib.Path("/etc/nginx/conf.d/dsh-mobile-gateway.conf")
|
|
23
|
+
WEBROOT = pathlib.Path("/var/lib/dsh-mobile-gateway/acme")
|
|
24
|
+
CERTBOT_HOME = pathlib.Path("/opt/dsh-mobile-gateway/certbot")
|
|
25
|
+
CERTBOT = CERTBOT_HOME / "bin/certbot"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(args):
|
|
29
|
+
subprocess.run(args, check=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def atomic_managed_write(target, content, mode=0o644):
|
|
33
|
+
target = pathlib.Path(target)
|
|
34
|
+
if target.exists() and not target.read_text(encoding="utf-8").startswith(MARKER):
|
|
35
|
+
raise RuntimeError(f"refusing to overwrite unmanaged file: {target}")
|
|
36
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
temporary = target.with_name(f"{target.name}.tmp-{os.getpid()}")
|
|
38
|
+
temporary.write_text(content, encoding="utf-8")
|
|
39
|
+
os.chmod(temporary, mode)
|
|
40
|
+
os.replace(temporary, target)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_public_ip(value):
|
|
44
|
+
try:
|
|
45
|
+
address = ipaddress.ip_address(value)
|
|
46
|
+
except ValueError as error:
|
|
47
|
+
raise ValueError("publicIp must be a valid IPv4 address") from error
|
|
48
|
+
if address.version != 4 or not address.is_global:
|
|
49
|
+
raise ValueError("publicIp must be a public IPv4 address")
|
|
50
|
+
return str(address)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_port(value):
|
|
54
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 65535:
|
|
55
|
+
raise ValueError("backendPort must be an integer between 1 and 65535")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def certificate_name(public_ip):
|
|
60
|
+
return f"dsh-mobile-gateway-{public_ip.replace('.', '-')}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def nginx_http_config(public_ip):
|
|
64
|
+
return f"""{MARKER}
|
|
65
|
+
server {{
|
|
66
|
+
listen 80;
|
|
67
|
+
server_name {public_ip};
|
|
68
|
+
|
|
69
|
+
location ^~ /.well-known/acme-challenge/ {{
|
|
70
|
+
root {WEBROOT};
|
|
71
|
+
default_type text/plain;
|
|
72
|
+
}}
|
|
73
|
+
|
|
74
|
+
location / {{ return 404; }}
|
|
75
|
+
}}
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def nginx_tls_config(public_ip, backend_port):
|
|
80
|
+
live = f"/etc/letsencrypt/live/{certificate_name(public_ip)}"
|
|
81
|
+
return nginx_http_config(public_ip) + f"""
|
|
82
|
+
server {{
|
|
83
|
+
listen 443 ssl;
|
|
84
|
+
server_name {public_ip};
|
|
85
|
+
server_tokens off;
|
|
86
|
+
access_log off;
|
|
87
|
+
|
|
88
|
+
ssl_certificate {live}/fullchain.pem;
|
|
89
|
+
ssl_certificate_key {live}/privkey.pem;
|
|
90
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
91
|
+
|
|
92
|
+
location = /ws/mobile {{
|
|
93
|
+
if ($args != \"\") {{ return 404; }}
|
|
94
|
+
proxy_pass http://127.0.0.1:{backend_port};
|
|
95
|
+
proxy_http_version 1.1;
|
|
96
|
+
proxy_set_header Host 127.0.0.1:{backend_port};
|
|
97
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
98
|
+
proxy_set_header Connection \"upgrade\";
|
|
99
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
100
|
+
proxy_read_timeout 3600s;
|
|
101
|
+
proxy_send_timeout 3600s;
|
|
102
|
+
proxy_buffering off;
|
|
103
|
+
}}
|
|
104
|
+
|
|
105
|
+
location / {{ return 404; }}
|
|
106
|
+
}}
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def current_status():
|
|
111
|
+
public_url = None
|
|
112
|
+
if PUBLIC_URL_FILE.exists():
|
|
113
|
+
public_url = next((line.strip() for line in PUBLIC_URL_FILE.read_text(encoding="utf-8").splitlines()
|
|
114
|
+
if line.strip().startswith("wss://")), None)
|
|
115
|
+
backend_port = None
|
|
116
|
+
if NGINX_CONFIG.exists():
|
|
117
|
+
match = re.search(r"proxy_pass http://127\.0\.0\.1:(\d+);", NGINX_CONFIG.read_text(encoding="utf-8"))
|
|
118
|
+
if match:
|
|
119
|
+
backend_port = int(match.group(1))
|
|
120
|
+
return {
|
|
121
|
+
"installed": True,
|
|
122
|
+
"version": VERSION,
|
|
123
|
+
"configured": bool(public_url and backend_port),
|
|
124
|
+
"publicUrl": public_url,
|
|
125
|
+
"backendPort": backend_port,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def ensure_dependencies():
|
|
130
|
+
if not shutil.which("nginx") or not CERTBOT.exists():
|
|
131
|
+
raise RuntimeError("helper dependencies are missing; rerun init to repair the installation")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def configure(public_ip, backend_port):
|
|
135
|
+
public_ip = validate_public_ip(public_ip)
|
|
136
|
+
backend_port = validate_port(backend_port)
|
|
137
|
+
previous = NGINX_CONFIG.read_bytes() if NGINX_CONFIG.exists() else None
|
|
138
|
+
if previous is not None and not previous.decode("utf-8").startswith(MARKER):
|
|
139
|
+
raise RuntimeError(f"refusing to overwrite unmanaged file: {NGINX_CONFIG}")
|
|
140
|
+
|
|
141
|
+
ensure_dependencies()
|
|
142
|
+
(WEBROOT / ".well-known/acme-challenge").mkdir(parents=True, exist_ok=True)
|
|
143
|
+
try:
|
|
144
|
+
atomic_managed_write(NGINX_CONFIG, nginx_http_config(public_ip))
|
|
145
|
+
run(["nginx", "-t"])
|
|
146
|
+
run(["systemctl", "enable", "--now", "nginx"])
|
|
147
|
+
run(["systemctl", "reload", "nginx"])
|
|
148
|
+
|
|
149
|
+
args = [
|
|
150
|
+
str(CERTBOT), "certonly", "--non-interactive", "--agree-tos",
|
|
151
|
+
"--preferred-profile", "shortlived", "--webroot", "--webroot-path", str(WEBROOT),
|
|
152
|
+
"--ip-address", public_ip, "--cert-name", certificate_name(public_ip),
|
|
153
|
+
"--keep-until-expiring", "--register-unsafely-without-email",
|
|
154
|
+
]
|
|
155
|
+
run(args)
|
|
156
|
+
atomic_managed_write(NGINX_CONFIG, nginx_tls_config(public_ip, backend_port))
|
|
157
|
+
atomic_managed_write(PUBLIC_URL_FILE, f"{MARKER}\nwss://{public_ip}/ws/mobile\n")
|
|
158
|
+
run(["nginx", "-t"])
|
|
159
|
+
run(["systemctl", "reload", "nginx"])
|
|
160
|
+
except Exception:
|
|
161
|
+
if previous is None:
|
|
162
|
+
NGINX_CONFIG.unlink(missing_ok=True)
|
|
163
|
+
else:
|
|
164
|
+
NGINX_CONFIG.write_bytes(previous)
|
|
165
|
+
if shutil.which("nginx"):
|
|
166
|
+
subprocess.run(["nginx", "-t"], check=False)
|
|
167
|
+
subprocess.run(["systemctl", "reload", "nginx"], check=False)
|
|
168
|
+
raise
|
|
169
|
+
return current_status()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def dispatch(message):
|
|
173
|
+
action = message.get("action")
|
|
174
|
+
if action == "status":
|
|
175
|
+
return current_status()
|
|
176
|
+
if action == "configure":
|
|
177
|
+
return configure(message.get("publicIp"), message.get("backendPort"))
|
|
178
|
+
raise ValueError("unsupported helper action")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Handler(socketserver.StreamRequestHandler):
|
|
182
|
+
def handle(self):
|
|
183
|
+
try:
|
|
184
|
+
raw = self.rfile.readline(65537)
|
|
185
|
+
if not raw or len(raw) > 65536:
|
|
186
|
+
raise ValueError("invalid helper request")
|
|
187
|
+
message = json.loads(raw.decode("utf-8"))
|
|
188
|
+
if not isinstance(message, dict):
|
|
189
|
+
raise ValueError("helper request must be an object")
|
|
190
|
+
response = {"ok": True, "result": dispatch(message)}
|
|
191
|
+
except Exception as error: # Return a typed error; never expose a traceback.
|
|
192
|
+
response = {"ok": False, "error": str(error)}
|
|
193
|
+
self.wfile.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Server(socketserver.UnixStreamServer):
|
|
197
|
+
allow_reuse_address = True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def serve(uid, socket_path):
|
|
201
|
+
if os.geteuid() != 0:
|
|
202
|
+
raise RuntimeError("helper must run as root")
|
|
203
|
+
path = pathlib.Path(socket_path)
|
|
204
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
path.unlink(missing_ok=True)
|
|
206
|
+
with Server(str(path), Handler) as server:
|
|
207
|
+
os.chown(path, uid, -1)
|
|
208
|
+
os.chmod(path, 0o600)
|
|
209
|
+
server.serve_forever()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main():
|
|
213
|
+
parser = argparse.ArgumentParser()
|
|
214
|
+
parser.add_argument("--uid", type=int, required=True)
|
|
215
|
+
parser.add_argument("--socket", default=SOCKET_PATH)
|
|
216
|
+
options = parser.parse_args()
|
|
217
|
+
if options.uid < 1:
|
|
218
|
+
raise ValueError("uid must identify a non-root user")
|
|
219
|
+
serve(options.uid, options.socket)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
try:
|
|
224
|
+
main()
|
|
225
|
+
except Exception as error:
|
|
226
|
+
print(f"dsh-mobile-gateway-helper: {error}", file=sys.stderr)
|
|
227
|
+
raise SystemExit(1)
|
package/lib/account-bridge.mjs
CHANGED
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Account bridge: proxy the Host's DeepSeek account projection to the mobile
|
|
3
|
-
* client. Injected only when the Host provides `accountController`; older hosts
|
|
4
|
-
* keep answering `account-unavailable`, which the client degrades gracefully.
|
|
5
|
-
*
|
|
6
|
-
* Wire shape (mobile -> host): { type: 'account', requestId, client }
|
|
7
|
-
* Wire shape (host -> mobile): { kind: 'account', requestId, state, profile, balance }
|
|
8
|
-
*/
|
|
9
|
-
export const ACCOUNT_REQUEST_TYPES = new Set(['account'])
|
|
10
|
-
|
|
11
|
-
const MAX_REQUEST_ID = 128
|
|
12
|
-
|
|
13
|
-
function normalizeClient(value) {
|
|
14
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
15
|
-
const version = typeof value.version === 'string' ? value.version.trim().slice(0, 64) : ''
|
|
16
|
-
if (!version) return undefined
|
|
17
|
-
const rawLocale = typeof value.locale === 'string' ? value.locale.trim().slice(0, 32) : ''
|
|
18
|
-
const offset = Number(value.timezoneOffsetSeconds)
|
|
19
|
-
return {
|
|
20
|
-
version,
|
|
21
|
-
locale: rawLocale || 'en',
|
|
22
|
-
timezoneOffsetSeconds: Number.isFinite(offset) ? Math.trunc(offset) : 0,
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function settle(promise) {
|
|
27
|
-
try { return Promise.resolve(promise).catch(() => null) } catch (_) { return Promise.resolve(null) }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function handleAccount(api, msg) {
|
|
31
|
-
const requestId = typeof msg?.requestId === 'string' ? msg.requestId.slice(0, MAX_REQUEST_ID) : ''
|
|
32
|
-
const fail = (code, message) => ({
|
|
33
|
-
kind: 'error', requestType: 'account', ...(requestId ? { requestId } : {}), code, message,
|
|
34
|
-
})
|
|
35
|
-
if (!requestId) return fail('bad-request', 'account requires a requestId')
|
|
36
|
-
const account = api?.account
|
|
37
|
-
if (!account || typeof account.getState !== 'function') {
|
|
38
|
-
return fail('account-unavailable', 'Account service is unavailable on this host')
|
|
39
|
-
}
|
|
40
|
-
const client = normalizeClient(msg.client)
|
|
41
|
-
try {
|
|
42
|
-
const state = (await settle(account.getState())) ?? null
|
|
43
|
-
let profile = null
|
|
44
|
-
let balance = null
|
|
45
|
-
if (client) {
|
|
46
|
-
const [profileOutcome, balanceOutcome] = await Promise.all([
|
|
47
|
-
typeof account.getProfile === 'function' ? settle(account.getProfile(client)) : null,
|
|
48
|
-
typeof account.getBalance === 'function' ? settle(account.getBalance(client)) : null,
|
|
49
|
-
])
|
|
50
|
-
profile = profileOutcome ?? null
|
|
51
|
-
balance = balanceOutcome ?? null
|
|
52
|
-
}
|
|
53
|
-
return { kind: 'account', requestId, state, profile, balance }
|
|
54
|
-
} catch (_) {
|
|
55
|
-
return fail('account-unavailable', 'Account information is unavailable')
|
|
56
|
-
}
|
|
57
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Account bridge: proxy the Host's DeepSeek account projection to the mobile
|
|
3
|
+
* client. Injected only when the Host provides `accountController`; older hosts
|
|
4
|
+
* keep answering `account-unavailable`, which the client degrades gracefully.
|
|
5
|
+
*
|
|
6
|
+
* Wire shape (mobile -> host): { type: 'account', requestId, client }
|
|
7
|
+
* Wire shape (host -> mobile): { kind: 'account', requestId, state, profile, balance }
|
|
8
|
+
*/
|
|
9
|
+
export const ACCOUNT_REQUEST_TYPES = new Set(['account'])
|
|
10
|
+
|
|
11
|
+
const MAX_REQUEST_ID = 128
|
|
12
|
+
|
|
13
|
+
function normalizeClient(value) {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
15
|
+
const version = typeof value.version === 'string' ? value.version.trim().slice(0, 64) : ''
|
|
16
|
+
if (!version) return undefined
|
|
17
|
+
const rawLocale = typeof value.locale === 'string' ? value.locale.trim().slice(0, 32) : ''
|
|
18
|
+
const offset = Number(value.timezoneOffsetSeconds)
|
|
19
|
+
return {
|
|
20
|
+
version,
|
|
21
|
+
locale: rawLocale || 'en',
|
|
22
|
+
timezoneOffsetSeconds: Number.isFinite(offset) ? Math.trunc(offset) : 0,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function settle(promise) {
|
|
27
|
+
try { return Promise.resolve(promise).catch(() => null) } catch (_) { return Promise.resolve(null) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function handleAccount(api, msg) {
|
|
31
|
+
const requestId = typeof msg?.requestId === 'string' ? msg.requestId.slice(0, MAX_REQUEST_ID) : ''
|
|
32
|
+
const fail = (code, message) => ({
|
|
33
|
+
kind: 'error', requestType: 'account', ...(requestId ? { requestId } : {}), code, message,
|
|
34
|
+
})
|
|
35
|
+
if (!requestId) return fail('bad-request', 'account requires a requestId')
|
|
36
|
+
const account = api?.account
|
|
37
|
+
if (!account || typeof account.getState !== 'function') {
|
|
38
|
+
return fail('account-unavailable', 'Account service is unavailable on this host')
|
|
39
|
+
}
|
|
40
|
+
const client = normalizeClient(msg.client)
|
|
41
|
+
try {
|
|
42
|
+
const state = (await settle(account.getState())) ?? null
|
|
43
|
+
let profile = null
|
|
44
|
+
let balance = null
|
|
45
|
+
if (client) {
|
|
46
|
+
const [profileOutcome, balanceOutcome] = await Promise.all([
|
|
47
|
+
typeof account.getProfile === 'function' ? settle(account.getProfile(client)) : null,
|
|
48
|
+
typeof account.getBalance === 'function' ? settle(account.getBalance(client)) : null,
|
|
49
|
+
])
|
|
50
|
+
profile = profileOutcome ?? null
|
|
51
|
+
balance = balanceOutcome ?? null
|
|
52
|
+
}
|
|
53
|
+
return { kind: 'account', requestId, state, profile, balance }
|
|
54
|
+
} catch (_) {
|
|
55
|
+
return fail('account-unavailable', 'Account information is unavailable')
|
|
56
|
+
}
|
|
57
|
+
}
|