mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(remote): the desktop host and the phone page can use a gateway the person runs
This commit is contained in:
1 parent
370b90716b
commit
24e6ccf64b
8 files changed
+169
-6
No files matched your search
@@ -516,6 +516,7 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
|
||||
dialer.NetDialContext = stream.DialContext
|
||||
client := account.New(os.Getenv("REASONIX_ACCOUNTS_URL"), "reasonix-studio/"+version, httpClient)
|
||||
host := remotecloud.New(client, &dialer, os.Getenv("REASONIX_REMOTE_URL"), version, tasks)
|
||||
host.SetGateway(os.Getenv("REASONIX_REMOTE_GATEWAY_ID"))
|
||||
if registrar, ok := tasks.(interface{ SetCloudControllerDisconnect(func(string) error) }); ok {
|
||||
registrar.SetCloudControllerDisconnect(host.DisconnectController)
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"远程连接已断开,请重新连接。": "The remote connection ended. Reconnect to continue.",
|
||||
"远程 Studio 拒绝了这次请求。": "Remote Studio refused this request.",
|
||||
"请先登录 Reasonix,再打开 Web Studio。": "Sign in to Reasonix before opening Web Studio.",
|
||||
"这台电脑使用的中继地址无法识别,请更新 Web Studio 后再试。": "The relay address this computer uses is not recognised. Update Web Studio and try again.",
|
||||
"请先更新这台电脑上的 Studio,再连接。": "Update Studio on this computer before connecting.",
|
||||
"无法授权 Web Studio,请重新登录后再试。": "Web Studio could not be authorized. Sign in again and retry.",
|
||||
"连接远程 Studio 超时,请检查电脑是否在线。": "Connecting to Remote Studio timed out. Check that the computer is online.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { onRemoteConnectionEnded, RemoteEventSource, remoteCodec, remoteConnectionEnded } from "./cloud_remote";
|
||||
import { onRemoteConnectionEnded, relayFor, RemoteEventSource, remoteCodec, remoteConnectionEnded } from "./cloud_remote";
|
||||
import { openLiveStream, STREAM_SILENCE_MS } from "./livestream";
|
||||
|
||||
describe("remote Studio binary framing", () => {
|
||||
@@ -216,3 +216,24 @@ describe("remote Studio binary framing", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("the relay a connection grant names", () => {
|
||||
const hosted = "wss://remote.reasonix.io";
|
||||
|
||||
it("is the hosted relay when the grant names none", () => {
|
||||
expect(relayFor(null, hosted)).toBe(hosted);
|
||||
expect(relayFor(undefined, hosted)).toBe(hosted);
|
||||
});
|
||||
|
||||
it("speaks wss to the https origin of a self-hosted gateway", () => {
|
||||
expect(relayFor("https://relay.example.com", hosted)).toBe("wss://relay.example.com");
|
||||
expect(relayFor("https://relay.example.com:8443/", hosted)).toBe("wss://relay.example.com:8443");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://relay.example.com", "wss://relay.example.com", "https://user:[email protected]", "https://relay.example.com/path",
|
||||
"https://relay.example.com/?x=1", "not a url", "", 7, {},
|
||||
])("refuses %j, so a ticket is never offered to a relay it was not issued for", (named) => {
|
||||
expect(relayFor(named, hosted)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,23 @@ export const accountOrigin = ACCOUNT;
|
||||
// The computer this page is controlling; null on the desktop's own window.
|
||||
export const remoteDeviceId = () => new URLSearchParams(location.search).get("device")?.trim() || null;
|
||||
const RELAY = (import.meta.env.VITE_REMOTE_GATEWAY || "wss://remote.reasonix.io").replace(/\/$/, "");
|
||||
|
||||
/** The relay a grant names. The account service sends the origin of the gateway
|
||||
* the device is bound to (`null` for the hosted one); it is only ever an https
|
||||
* origin, which is spoken to as wss. Anything else is refused: a ticket for a
|
||||
* self-hosted gateway must not be offered to another relay. */
|
||||
export function relayFor(named: unknown, hosted: string = RELAY): string | null {
|
||||
if (named === null || named === undefined) return hosted;
|
||||
if (typeof named !== "string") return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(named);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) return null;
|
||||
return `wss://${url.host}`;
|
||||
}
|
||||
const REMOTE_HOME = import.meta.env.VITE_REMOTE_HOME || "https://reasonix.io/remote/";
|
||||
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
||||
|
||||
@@ -274,12 +291,14 @@ async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap
|
||||
throw new RemoteLinkError("transient", failureView(detail).body, detail);
|
||||
}
|
||||
if (!issued.ok) throw await grantFailure(issued);
|
||||
const { grant, device: target } = await issued.json() as { grant: { ticket: string }; device?: RemoteDevice };
|
||||
const { grant, device: target, relay: named } = await issued.json() as { grant: { ticket: string }; device?: RemoteDevice; relay?: unknown };
|
||||
const relay = relayFor(named);
|
||||
if (!relay) throw new RemoteLinkError("ended", t("这台电脑使用的中继地址无法识别,请更新 Web Studio 后再试。"));
|
||||
if (!target || target.id !== deviceId || target.revokedAt || !target.capabilities.includes("desktop")) {
|
||||
throw new RemoteLinkError("ended", t("请先更新这台电脑上的 Studio,再连接。"));
|
||||
}
|
||||
performance.mark("reasonix:remote:authorized");
|
||||
const socket = new WebSocket(`${RELAY}/v1/sessions/connect`, ["reasonix.remote.v1", `reasonix.auth.${grant.ticket}`]);
|
||||
const socket = new WebSocket(`${relay}/v1/sessions/connect`, ["reasonix.remote.v1", `reasonix.auth.${grant.ticket}`]);
|
||||
try {
|
||||
await waitForSocketOpen(socket);
|
||||
const readyWire = nextMessage(socket);
|
||||
@@ -307,7 +326,7 @@ async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap
|
||||
if (error instanceof RemoteLinkError) throw error;
|
||||
if (error instanceof SocketRefused) {
|
||||
const detail = await classifyHandshakeFailure({
|
||||
relay: RELAY, online: () => navigator.onLine !== false, fetch: nativeFetch,
|
||||
relay, online: () => navigator.onLine !== false, fetch: nativeFetch,
|
||||
}).catch(() => ({ code: "unknown" as const }));
|
||||
throw new RemoteLinkError("transient", failureView(detail).body, detail);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ type RemoteDevice struct {
|
||||
Capabilities []RemoteCapability `json:"capabilities"`
|
||||
LastSeenAt *string `json:"lastSeenAt"`
|
||||
RevokedAt *string `json:"revokedAt"`
|
||||
// GatewayID is the self-hosted gateway the device is bound to; empty is the
|
||||
// hosted one.
|
||||
GatewayID string `json:"gatewayId,omitempty"`
|
||||
}
|
||||
|
||||
type RemoteDeviceRegistration struct {
|
||||
@@ -31,6 +34,7 @@ type RemoteDeviceRegistration struct {
|
||||
Platform string `json:"platform"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
Capabilities []RemoteCapability `json:"capabilities"`
|
||||
GatewayID string `json:"gatewayId,omitempty"`
|
||||
}
|
||||
|
||||
type RegisteredRemoteDevice struct {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package remotecloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/account"
|
||||
)
|
||||
|
||||
func hostAnswering(t *testing.T, devices []account.RemoteDevice) *Host {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/me/devices" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"devices": devices})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return New(account.New(srv.URL, "test", srv.Client()), nil, "", "test")
|
||||
}
|
||||
|
||||
// A saved identity belongs to one gateway. Pointing the machine at another (or
|
||||
// back at the hosted one) has to register the device again, or the account
|
||||
// service would keep answering for it at the old gateway.
|
||||
func TestASavedIdentityIsCurrentOnlyForTheGatewayItWasRegisteredAt(t *testing.T) {
|
||||
saved := &identity{DeviceID: "dev"}
|
||||
device := func(gateway string) []account.RemoteDevice {
|
||||
return []account.RemoteDevice{{ID: "dev", Capabilities: []account.RemoteCapability{account.RemoteDesktop}, GatewayID: gateway}}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
bound string
|
||||
devices []account.RemoteDevice
|
||||
want bool
|
||||
}{
|
||||
{"hosted device, hosted host", "", device(""), true},
|
||||
{"self-hosted device, same gateway", "g1", device("g1"), true},
|
||||
{"self-hosted device, host moved to the hosted gateway", "", device("g1"), false},
|
||||
{"hosted device, host moved to a self-hosted gateway", "g1", device(""), false},
|
||||
{"self-hosted device, host moved to another gateway", "g2", device("g1"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := hostAnswering(t, tc.devices)
|
||||
h.SetGateway(tc.bound)
|
||||
if got := h.identityCurrent(context.Background(), "token", saved); got != tc.want {
|
||||
t.Fatalf("identityCurrent = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestARelayThatIsNotWSSIsRefusedBeforeAnyCredentialIsSent(t *testing.T) {
|
||||
for _, url := range []string{"ws://relay.example.com", "http://relay.example.com", "relay.example.com"} {
|
||||
h := New(account.New("", "test", nil), nil, url, "test")
|
||||
err := h.connect(context.Background(), "token", &identity{DeviceID: "dev", DeviceCredential: "secret"}, nil)
|
||||
if err == nil || unavailableReasonFor(err) != unavailableRelayRefused {
|
||||
t.Fatalf("connect(%q) = %v (reason %q), want a typed relay refusal", url, err, unavailableReasonFor(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelaySecureAdmitsWSSAndOnlyLoopbackWS(t *testing.T) {
|
||||
for url, want := range map[string]bool{
|
||||
"wss://relay.example.com": true, "ws://127.0.0.1:8080": true, "ws://localhost:9": true, "ws://[::1]:1": true,
|
||||
"ws://relay.example.com": false, "ws://10.0.0.5": false, "https://relay.example.com": false, "wss://": false, "": false,
|
||||
} {
|
||||
if got := relaySecure(url); got != want {
|
||||
t.Errorf("relaySecure(%q) = %v, want %v", url, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGatewayTrimsTheID(t *testing.T) {
|
||||
h := New(account.New("", "test", nil), nil, "", "test")
|
||||
h.SetGateway(" g1 \n")
|
||||
if h.gatewayID != "g1" {
|
||||
t.Fatalf("gatewayID = %q", h.gatewayID)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
@@ -32,6 +34,7 @@ const desktopResponseChunk = 24 << 10
|
||||
var (
|
||||
errAccountChanged = errors.New("remote cloud: account changed")
|
||||
errRelayRefused = errors.New("remote cloud: relay refused the connection")
|
||||
errRelayInsecure = errors.New("remote cloud: the relay address must start with wss://")
|
||||
)
|
||||
|
||||
type unavailableReason string
|
||||
@@ -58,6 +61,7 @@ type Host struct {
|
||||
client *account.Client
|
||||
dialer *websocket.Dialer
|
||||
relayURL string
|
||||
gatewayID string
|
||||
version string
|
||||
name string
|
||||
token func() string
|
||||
@@ -149,6 +153,29 @@ func New(client *account.Client, dialer *websocket.Dialer, relayURL, version str
|
||||
// DisconnectController asks the relay to close one authenticated controller.
|
||||
// The live connection owns WebSocket writes, so callers hand the request to it
|
||||
// instead of writing concurrently from the local HTTP handler.
|
||||
// relaySecure admits wss://, and ws:// only to this machine, where nothing
|
||||
// crosses a network. A device credential must never be sent in the clear.
|
||||
func relaySecure(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "wss":
|
||||
return u.Host != ""
|
||||
case "ws":
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
return u.Hostname() == "localhost" || (ip != nil && ip.IsLoopback())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetGateway binds this machine to a gateway the person registered with the
|
||||
// account service. The relay address is the gateway's own; the id is what the
|
||||
// account service uses to scope the gateway's token to this device. Call it
|
||||
// before Run.
|
||||
func (h *Host) SetGateway(id string) { h.gatewayID = strings.TrimSpace(id) }
|
||||
|
||||
func (h *Host) DisconnectController(id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if len(id) != 32 {
|
||||
@@ -233,7 +260,7 @@ func unavailableReasonFor(err error) unavailableReason {
|
||||
if errors.Is(err, account.ErrUnauthorized) {
|
||||
return unavailableSignedOut
|
||||
}
|
||||
if errors.Is(err, errRelayRefused) {
|
||||
if errors.Is(err, errRelayRefused) || errors.Is(err, errRelayInsecure) {
|
||||
return unavailableRelayRefused
|
||||
}
|
||||
var closeErr *websocket.CloseError
|
||||
@@ -272,7 +299,7 @@ func (h *Host) identityCurrent(ctx context.Context, token string, saved *identit
|
||||
}
|
||||
for _, device := range devices {
|
||||
if device.ID == saved.DeviceID {
|
||||
return slices.Contains(device.Capabilities, account.RemoteDesktop)
|
||||
return device.GatewayID == h.gatewayID && slices.Contains(device.Capabilities, account.RemoteDesktop)
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -289,6 +316,7 @@ func (h *Host) registerIdentityWithPublic(ctx context.Context, token string, own
|
||||
Capabilities: []account.RemoteCapability{
|
||||
account.RemoteTasks, account.RemoteLogs, account.RemoteFiles, account.RemoteDesktop,
|
||||
},
|
||||
GatewayID: h.gatewayID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -335,6 +363,9 @@ type controllerCommand struct {
|
||||
}
|
||||
|
||||
func (h *Host) connect(ctx context.Context, token string, saved *identity, private *ecdh.PrivateKey) error {
|
||||
if !relaySecure(h.relayURL) {
|
||||
return errRelayInsecure
|
||||
}
|
||||
url := h.relayURL + "/v1/devices/" + saved.DeviceID + "/connect"
|
||||
headers := http.Header{
|
||||
"Authorization": []string{"Bearer " + saved.DeviceCredential},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- 新增:桌面端可连自己部署的远程网关(REASONIX_REMOTE_URL、REASONIX_REMOTE_GATEWAY_ID),手机网页按授权里的网关地址连接;非 wss:// 地址会被拒绝。关联 #14282。
|
||||
- Added: the desktop host can use a remote gateway you run (REASONIX_REMOTE_URL, REASONIX_REMOTE_GATEWAY_ID); the phone page follows the grant's gateway; non-wss:// relays are refused. Refs #14282.
|
||||
Reference in new issue
Block a user