feat(remote): the desktop host and the phone page can use a gateway the person runs

This commit is contained in:
esengine committed 2026-10-23 04:38:13 -07:00
1 parent 370b90716b
commit 24e6ccf64b
8 files changed
+169 -6

No files matched your search

+1
View File
@@ -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();
});
});
+22 -3
View File
@@ -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);
}
+4
View File
@@ -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)
}
}
+33 -2
View File
@@ -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.